diff --git a/doc/discretization.rst b/doc/discretization.rst
index aa0fd8547f935cc93a8c4aa2ce5aada316a7f13f..d046d7ab90a3669138151c527686a86ab7061563 100644
--- a/doc/discretization.rst
+++ b/doc/discretization.rst
@@ -1,5 +1,5 @@
-Discretization
-==============
+Discretization Collection
+=========================
 
 .. module:: grudge
 
diff --git a/doc/geometry.rst b/doc/geometry.rst
new file mode 100644
index 0000000000000000000000000000000000000000..d7047806b78d76c115cf9a7d04bed33d15511d00
--- /dev/null
+++ b/doc/geometry.rst
@@ -0,0 +1,4 @@
+Metric terms and transformations
+================================
+
+.. automodule:: grudge.geometry.metrics
diff --git a/doc/index.rst b/doc/index.rst
index 1e459c7ced1d66eb8c6379f7c1f9d83d2179441f..d113cce65741f2d97f0f89b9248587ab4a2a4c5a 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -8,7 +8,8 @@ Contents:
 
     discretization
     dof_desc
-    symbolic
+    geometry
+    operators
     misc
     🚀 Github <https://github.com/inducer/grudge>
     💾 Download Releases <https://pypi.org/project/grudge>
diff --git a/doc/operators.rst b/doc/operators.rst
new file mode 100644
index 0000000000000000000000000000000000000000..a51d0f2a0e0e70c0989f5ac36c70d22323e0bc40
--- /dev/null
+++ b/doc/operators.rst
@@ -0,0 +1,5 @@
+Discontinuous Galerkin Operators
+================================
+
+.. automodule:: grudge.op
+.. automodule:: grudge.trace_pair
diff --git a/doc/symbolic.rst b/doc/symbolic.rst
deleted file mode 100644
index 632b1f861beaa80c3f3c26e93b08272ec4a31259..0000000000000000000000000000000000000000
--- a/doc/symbolic.rst
+++ /dev/null
@@ -1,14 +0,0 @@
-Symbolic Operator Representation
-================================
-
-Based on :mod:`pymbolic`.
-
-Basic Objects
--------------
-
-.. automodule:: grudge.symbolic.primitives
-
-Operators
----------
-
-.. automodule:: grudge.symbolic.operators
diff --git a/examples/advection/surface.py b/examples/advection/surface.py
index d528f5f3eee29366d9c963c3dbd5fe410e1819fa..74771af007aa2b3c1f124e0976140cdb4793e28e 100644
--- a/examples/advection/surface.py
+++ b/examples/advection/surface.py
@@ -1,4 +1,9 @@
-__copyright__ = "Copyright (C) 2020 Alexandru Fikl"
+"""Minimal example of a grudge driver for DG on surfaces."""
+
+__copyright__ = """
+Copyright (C) 2020 Alexandru Fikl
+Copyright (C) 2021 University of Illinois Board of Trustees
+"""
 
 __license__ = """
 Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -24,15 +29,17 @@ import os
 
 import numpy as np
 import pyopencl as cl
+import pyopencl.tools as cl_tools
 
-from meshmode.array_context import PyOpenCLArrayContext
-from meshmode.dof_array import thaw, flatten
+from arraycontext import PyOpenCLArrayContext, thaw
+
+from meshmode.dof_array import flatten
 from meshmode.discretization.connection import FACE_RESTR_INTERIOR
 
-from grudge import bind, sym
 from pytools.obj_array import make_obj_array
 
 import grudge.dof_desc as dof_desc
+import grudge.op as op
 
 import logging
 logger = logging.getLogger(__name__)
@@ -41,10 +48,10 @@ logger = logging.getLogger(__name__)
 # {{{ plotting (keep in sync with `var-velocity.py`)
 
 class Plotter:
-    def __init__(self, actx, discr, order, visualize=True):
+    def __init__(self, actx, dcoll, order, visualize=True):
         self.actx = actx
-        self.ambient_dim = discr.ambient_dim
-        self.dim = discr.dim
+        self.ambient_dim = dcoll.ambient_dim
+        self.dim = dcoll.dim
 
         self.visualize = visualize
         if not self.visualize:
@@ -54,11 +61,11 @@ class Plotter:
             import matplotlib.pyplot as pt
             self.fig = pt.figure(figsize=(8, 8), dpi=300)
 
-            x = thaw(actx, discr.discr_from_dd(dof_desc.DD_VOLUME).nodes())
-            self.x = actx.to_numpy(flatten(actx.np.atan2(x[1], x[0])))
+            x = thaw(dcoll.discr_from_dd(dof_desc.DD_VOLUME).nodes(), actx)
+            self.x = actx.to_numpy(flatten(actx.np.arctan2(x[1], x[0])))
         elif self.ambient_dim == 3:
             from grudge.shortcuts import make_visualizer
-            self.vis = make_visualizer(discr)
+            self.vis = make_visualizer(dcoll)
         else:
             raise ValueError("unsupported dimension")
 
@@ -94,10 +101,13 @@ class Plotter:
 # }}}
 
 
-def main(ctx_factory, dim=2, order=4, product_tag=None, visualize=False):
+def main(ctx_factory, dim=2, order=4, use_quad=False, visualize=False):
     cl_ctx = ctx_factory()
     queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+    actx = PyOpenCLArrayContext(
+        queue,
+        allocator=cl_tools.MemoryPool(cl_tools.ImmediateAllocator(queue))
+    )
 
     # {{{ parameters
 
@@ -111,11 +121,6 @@ def main(ctx_factory, dim=2, order=4, product_tag=None, visualize=False):
     # final time
     final_time = np.pi
 
-    # velocity field
-    sym_x = sym.nodes(dim)
-    c = make_obj_array([
-        -sym_x[1], sym_x[0], 0.0
-        ])[:dim]
     # flux
     flux_type = "lf"
 
@@ -137,10 +142,10 @@ def main(ctx_factory, dim=2, order=4, product_tag=None, visualize=False):
         raise ValueError("unsupported dimension")
 
     discr_tag_to_group_factory = {}
-    if product_tag == "none":
-        product_tag = None
+    if use_quad:
+        qtag = dof_desc.DISCR_TAG_QUAD
     else:
-        product_tag = dof_desc.DISCR_TAG_QUAD
+        qtag = None
 
     from meshmode.discretization.poly_element import \
             PolynomialWarpAndBlendGroupFactory, \
@@ -149,42 +154,51 @@ def main(ctx_factory, dim=2, order=4, product_tag=None, visualize=False):
     discr_tag_to_group_factory[dof_desc.DISCR_TAG_BASE] = \
         PolynomialWarpAndBlendGroupFactory(order)
 
-    if product_tag:
-        discr_tag_to_group_factory[product_tag] = \
+    if use_quad:
+        discr_tag_to_group_factory[qtag] = \
             QuadratureSimplexGroupFactory(order=4*order)
 
     from grudge import DiscretizationCollection
-    discr = DiscretizationCollection(
+
+    dcoll = DiscretizationCollection(
         actx, mesh,
         discr_tag_to_group_factory=discr_tag_to_group_factory
     )
 
-    volume_discr = discr.discr_from_dd(dof_desc.DD_VOLUME)
+    volume_discr = dcoll.discr_from_dd(dof_desc.DD_VOLUME)
     logger.info("ndofs:     %d", volume_discr.ndofs)
     logger.info("nelements: %d", volume_discr.mesh.nelements)
 
     # }}}
 
-    # {{{ symbolic operators
+    # {{{ Surface advection operator
+
+    # velocity field
+    x = thaw(op.nodes(dcoll), actx)
+    c = make_obj_array([-x[1], x[0], 0.0])[:dim]
 
     def f_initial_condition(x):
         return x[0]
 
     from grudge.models.advection import SurfaceAdvectionOperator
-    op = SurfaceAdvectionOperator(c,
+    adv_operator = SurfaceAdvectionOperator(
+        dcoll,
+        c,
         flux_type=flux_type,
-        quad_tag=product_tag)
+        quad_tag=qtag
+    )
 
-    bound_op = bind(discr, op.sym_operator())
-    u0 = bind(discr, f_initial_condition(sym_x))(actx, t=0)
+    u0 = f_initial_condition(x)
 
     def rhs(t, u):
-        return bound_op(actx, t=t, u=u)
+        return adv_operator.operator(t, u)
 
     # check velocity is tangential
-    sym_normal = sym.surface_normal(dim, dim=dim - 1,
-                                    dd=dof_desc.DD_VOLUME).as_vector()
-    error = bind(discr, sym.norm(2, c.dot(sym_normal)))(actx)
+    from grudge.geometry import normal
+
+    surf_normal = normal(actx, dcoll, dd=dof_desc.DD_VOLUME)
+
+    error = op.norm(dcoll, c.dot(surf_normal), 2)
     logger.info("u_dot_n:   %.5e", error)
 
     # }}}
@@ -192,8 +206,7 @@ def main(ctx_factory, dim=2, order=4, product_tag=None, visualize=False):
     # {{{ time stepping
 
     # compute time step
-    h_min = bind(discr,
-            sym.h_max_from_volume(discr.ambient_dim, dim=discr.dim))(actx)
+    h_min = op.h_max_from_volume(dcoll, dim=dcoll.dim)
     dt = dt_factor * h_min/order**2
     nsteps = int(final_time // dt) + 1
     dt = final_time/nsteps + 1.0e-15
@@ -203,10 +216,9 @@ def main(ctx_factory, dim=2, order=4, product_tag=None, visualize=False):
 
     from grudge.shortcuts import set_up_rk4
     dt_stepper = set_up_rk4("u", dt, u0, rhs)
-    plot = Plotter(actx, discr, order, visualize=visualize)
+    plot = Plotter(actx, dcoll, order, visualize=visualize)
 
-    norm = bind(discr, sym.norm(2, sym.var("u")))
-    norm_u = norm(actx, u=u0)
+    norm_u = op.norm(dcoll, u0, 2)
 
     step = 0
 
@@ -215,17 +227,19 @@ def main(ctx_factory, dim=2, order=4, product_tag=None, visualize=False):
 
     if visualize and dim == 3:
         from grudge.shortcuts import make_visualizer
-        vis = make_visualizer(discr)
-        vis.write_vtk_file("fld-surface-velocity.vtu", [
-            ("u", bind(discr, c)(actx)),
-            ("n", bind(discr, sym_normal)(actx))
-            ], overwrite=True)
+        vis = make_visualizer(dcoll)
+        vis.write_vtk_file(
+            "fld-surface-velocity.vtu",
+            [
+                ("u", c),
+                ("n", surf_normal)
+            ],
+            overwrite=True
+        )
 
         df = dof_desc.DOFDesc(FACE_RESTR_INTERIOR)
-        face_discr = discr.connection_from_dds(dof_desc.DD_VOLUME, df).to_discr
-
-        face_normal = bind(discr, sym.normal(
-            df, face_discr.ambient_dim, dim=face_discr.dim))(actx)
+        face_discr = dcoll.discr_from_dd(df)
+        face_normal = thaw(op.normal(dcoll, dd=df), actx)
 
         from meshmode.discretization.visualization import make_visualizer
         vis = make_visualizer(actx, face_discr)
@@ -239,12 +253,14 @@ def main(ctx_factory, dim=2, order=4, product_tag=None, visualize=False):
 
         step += 1
         if step % 10 == 0:
-            norm_u = norm(actx, u=event.state_component)
+            norm_u = op.norm(dcoll, event.state_component, 2)
             plot(event, "fld-surface-%04d" % step)
 
         logger.info("[%04d] t = %.5f |u| = %.5e", step, event.t, norm_u)
 
-    plot(event, "fld-surface-%04d" % step)
+        # NOTE: These are here to ensure the solution is bounded for the
+        # time interval specified
+        assert norm_u < 3
 
     # }}}
 
@@ -254,10 +270,12 @@ if __name__ == "__main__":
 
     parser = argparse.ArgumentParser()
     parser.add_argument("--dim", choices=[2, 3], default=2, type=int)
-    parser.add_argument("--qtag", choices=["none", "product"], default="none")
+    parser.add_argument("--use-quad", action="store_false")
+    parser.add_argument("--visualize", action="store_true")
     args = parser.parse_args()
 
     logging.basicConfig(level=logging.INFO)
     main(cl.create_some_context,
             dim=args.dim,
-            product_tag=args.qtag)
+            use_quad=args.use_quad,
+            visualize=args.visualize)
diff --git a/examples/advection/var-velocity.py b/examples/advection/var-velocity.py
index 5843b99318cb65de77e53e9a78669a2c001dee2c..1d10c917d6ba03ec2922dcf6dfaa131c51a55aca 100644
--- a/examples/advection/var-velocity.py
+++ b/examples/advection/var-velocity.py
@@ -1,4 +1,9 @@
-__copyright__ = "Copyright (C) 2017 Bogdan Enache"
+"""Minimal example of a grudge driver."""
+
+__copyright__ = """
+Copyright (C) 2017 Bogdan Enache
+Copyright (C) 2021 University of Illinois Board of Trustees
+"""
 
 __license__ = """
 Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -24,15 +29,17 @@ import os
 import numpy as np
 
 import pyopencl as cl
+import pyopencl.tools as cl_tools
+
+from arraycontext import PyOpenCLArrayContext, thaw
 
-from meshmode.array_context import PyOpenCLArrayContext
-from meshmode.dof_array import thaw, flatten
+from meshmode.dof_array import flatten
 from meshmode.mesh import BTAG_ALL
 
-from grudge import bind, sym
 from pytools.obj_array import flat_obj_array
 
 import grudge.dof_desc as dof_desc
+import grudge.op as op
 
 import logging
 logger = logging.getLogger(__name__)
@@ -41,9 +48,9 @@ logger = logging.getLogger(__name__)
 # {{{ plotting (keep in sync with `weak.py`)
 
 class Plotter:
-    def __init__(self, actx, discr, order, visualize=True, ylim=None):
+    def __init__(self, actx, dcoll, order, visualize=True, ylim=None):
         self.actx = actx
-        self.dim = discr.ambient_dim
+        self.dim = dcoll.ambient_dim
 
         self.visualize = visualize
         if not self.visualize:
@@ -54,11 +61,11 @@ class Plotter:
             self.fig = pt.figure(figsize=(8, 8), dpi=300)
             self.ylim = ylim
 
-            volume_discr = discr.discr_from_dd(dof_desc.DD_VOLUME)
-            self.x = actx.to_numpy(flatten(thaw(actx, volume_discr.nodes()[0])))
+            volume_discr = dcoll.discr_from_dd(dof_desc.DD_VOLUME)
+            self.x = actx.to_numpy(flatten(thaw(volume_discr.nodes()[0], actx)))
         else:
             from grudge.shortcuts import make_visualizer
-            self.vis = make_visualizer(discr)
+            self.vis = make_visualizer(dcoll)
 
     def __call__(self, evt, basename, overwrite=True):
         if not self.visualize:
@@ -91,10 +98,13 @@ class Plotter:
 # }}}
 
 
-def main(ctx_factory, dim=2, order=4, product_tag=None, visualize=False):
+def main(ctx_factory, dim=2, order=4, use_quad=False, visualize=False):
     cl_ctx = ctx_factory()
     queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+    actx = PyOpenCLArrayContext(
+        queue,
+        allocator=cl_tools.MemoryPool(cl_tools.ImmediateAllocator(queue))
+    )
 
     # {{{ parameters
 
@@ -116,16 +126,11 @@ def main(ctx_factory, dim=2, order=4, product_tag=None, visualize=False):
 
     # flux
     flux_type = "upwind"
-    # velocity field
-    sym_x = sym.nodes(dim)
-    if dim == 1:
-        c = sym_x
+
+    if use_quad:
+        qtag = dof_desc.DISCR_TAG_QUAD
     else:
-        # solid body rotation
-        c = flat_obj_array(
-                np.pi * (d/2 - sym_x[1]),
-                np.pi * (sym_x[0] - d/2),
-                0)[:dim]
+        qtag = None
 
     # }}}
 
@@ -140,51 +145,63 @@ def main(ctx_factory, dim=2, order=4, product_tag=None, visualize=False):
     from meshmode.discretization.poly_element import \
             QuadratureSimplexGroupFactory
 
-    if product_tag:
+    if use_quad:
         discr_tag_to_group_factory = {
-            product_tag: QuadratureSimplexGroupFactory(order=4*order)
+            qtag: QuadratureSimplexGroupFactory(order=4*order)
         }
     else:
         discr_tag_to_group_factory = {}
 
     from grudge import DiscretizationCollection
-    discr = DiscretizationCollection(
+
+    dcoll = DiscretizationCollection(
         actx, mesh, order=order,
         discr_tag_to_group_factory=discr_tag_to_group_factory
     )
 
     # }}}
 
-    # {{{ symbolic operators
+    # {{{ advection operator
 
     # gaussian parameters
     source_center = np.array([0.5, 0.75, 0.0])[:dim]
     source_width = 0.05
-    dist_squared = np.dot(sym_x - source_center, sym_x - source_center)
 
     def f_gaussian(x):
-        return sym.exp(-dist_squared / source_width**2)
+        return actx.np.exp(-np.dot(x - source_center,
+                                   x - source_center) / source_width**2)
 
-    def f_step(x):
-        return sym.If(sym.Comparison(
-            dist_squared, "<", (4*source_width) ** 2),
-            1, 0)
-
-    def u_bc(x):
-        return 0.0
+    def zero_inflow_bc(dtag, t=0):
+        dd = dof_desc.DOFDesc(dtag, qtag)
+        return dcoll.discr_from_dd(dd).zeros(actx)
 
     from grudge.models.advection import VariableCoefficientAdvectionOperator
-    op = VariableCoefficientAdvectionOperator(
-            c,
-            u_bc(sym.nodes(dim, BTAG_ALL)),
-            quad_tag=product_tag,
-            flux_type=flux_type)
 
-    bound_op = bind(discr, op.sym_operator())
-    u = bind(discr, f_gaussian(sym.nodes(dim)))(actx, t=0)
+    x = thaw(op.nodes(dcoll), actx)
+
+    # velocity field
+    if dim == 1:
+        c = x
+    else:
+        # solid body rotation
+        c = flat_obj_array(
+            np.pi * (d/2 - x[1]),
+            np.pi * (x[0] - d/2),
+            0
+        )[:dim]
+
+    adv_operator = VariableCoefficientAdvectionOperator(
+        dcoll,
+        c,
+        inflow_u=lambda t: zero_inflow_bc(BTAG_ALL, t),
+        quad_tag=qtag,
+        flux_type=flux_type
+    )
+
+    u = f_gaussian(x)
 
     def rhs(t, u):
-        return bound_op(t=t, u=u)
+        return adv_operator.operator(t, u)
 
     # }}}
 
@@ -192,22 +209,25 @@ def main(ctx_factory, dim=2, order=4, product_tag=None, visualize=False):
 
     from grudge.shortcuts import set_up_rk4
     dt_stepper = set_up_rk4("u", dt, u, rhs)
-    plot = Plotter(actx, discr, order, visualize=visualize,
+    plot = Plotter(actx, dcoll, order, visualize=visualize,
             ylim=[-0.1, 1.1])
 
     step = 0
-    norm = bind(discr, sym.norm(2, sym.var("u")))
     for event in dt_stepper.run(t_end=final_time):
         if not isinstance(event, dt_stepper.StateComputed):
             continue
 
         if step % 10 == 0:
-            norm_u = norm(u=event.state_component)
+            norm_u = op.norm(dcoll, event.state_component, 2)
             plot(event, "fld-var-velocity-%04d" % step)
 
         step += 1
         logger.info("[%04d] t = %.5f |u| = %.5e", step, event.t, norm_u)
 
+        # NOTE: These are here to ensure the solution is bounded for the
+        # time interval specified
+        assert norm_u < 1
+
     # }}}
 
 
@@ -216,10 +236,10 @@ if __name__ == "__main__":
 
     parser = argparse.ArgumentParser()
     parser.add_argument("--dim", default=2, type=int)
-    parser.add_argument("--qtag", default="product")
+    parser.add_argument("--use-quad", action="store_false")
     args = parser.parse_args()
 
     logging.basicConfig(level=logging.INFO)
     main(cl.create_some_context,
             dim=args.dim,
-            product_tag=args.qtag)
+            use_quad=args.use_quad)
diff --git a/examples/advection/weak.py b/examples/advection/weak.py
index b92d0a46bff61149362ea9080d92580e5eb601d1..5c9ad1098033532c6782a2c1b730a5252358c2e3 100644
--- a/examples/advection/weak.py
+++ b/examples/advection/weak.py
@@ -1,4 +1,9 @@
-__copyright__ = "Copyright (C) 2007 Andreas Kloeckner"
+"""Minimal example of a grudge driver."""
+
+__copyright__ = """
+Copyright (C) 2007 Andreas Kloeckner
+Copyright (C) 2021 University of Illinois Board of Trustees
+"""
 
 __license__ = """
 Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -25,14 +30,15 @@ import numpy as np
 import numpy.linalg as la
 
 import pyopencl as cl
+import pyopencl.tools as cl_tools
 
-from meshmode.array_context import PyOpenCLArrayContext
-from meshmode.dof_array import thaw, flatten
-from meshmode.mesh import BTAG_ALL
+from arraycontext import PyOpenCLArrayContext, thaw
 
-from grudge import bind, sym
+from meshmode.dof_array import flatten
+from meshmode.mesh import BTAG_ALL
 
 import grudge.dof_desc as dof_desc
+import grudge.op as op
 
 import logging
 logger = logging.getLogger(__name__)
@@ -41,9 +47,9 @@ logger = logging.getLogger(__name__)
 # {{{ plotting (keep in sync with `var-velocity.py`)
 
 class Plotter:
-    def __init__(self, actx, discr, order, visualize=True, ylim=None):
+    def __init__(self, actx, dcoll, order, visualize=True, ylim=None):
         self.actx = actx
-        self.dim = discr.ambient_dim
+        self.dim = dcoll.ambient_dim
 
         self.visualize = visualize
         if not self.visualize:
@@ -54,11 +60,11 @@ class Plotter:
             self.fig = pt.figure(figsize=(8, 8), dpi=300)
             self.ylim = ylim
 
-            volume_discr = discr.discr_from_dd(dof_desc.DD_VOLUME)
-            self.x = actx.to_numpy(flatten(thaw(actx, volume_discr.nodes()[0])))
+            volume_discr = dcoll.discr_from_dd(dof_desc.DD_VOLUME)
+            self.x = actx.to_numpy(flatten(thaw(volume_discr.nodes()[0], actx)))
         else:
             from grudge.shortcuts import make_visualizer
-            self.vis = make_visualizer(discr)
+            self.vis = make_visualizer(dcoll)
 
     def __call__(self, evt, basename, overwrite=True):
         if not self.visualize:
@@ -95,7 +101,10 @@ class Plotter:
 def main(ctx_factory, dim=2, order=4, visualize=False):
     cl_ctx = ctx_factory()
     queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+    actx = PyOpenCLArrayContext(
+        queue,
+        allocator=cl_tools.MemoryPool(cl_tools.ImmediateAllocator(queue))
+    )
 
     # {{{ parameters
 
@@ -131,29 +140,36 @@ def main(ctx_factory, dim=2, order=4, visualize=False):
             order=order)
 
     from grudge import DiscretizationCollection
-    discr = DiscretizationCollection(actx, mesh, order=order)
+
+    dcoll = DiscretizationCollection(actx, mesh, order=order)
 
     # }}}
 
-    # {{{ symbolic operators
+    # {{{ weak advection operator
 
     def f(x):
-        return sym.sin(3 * x)
+        return actx.np.sin(3 * x)
 
-    def u_analytic(x):
-        t = sym.var("t", dof_desc.DD_SCALAR)
+    def u_analytic(x, t=0):
         return f(-np.dot(c, x) / norm_c + t * norm_c)
 
     from grudge.models.advection import WeakAdvectionOperator
-    op = WeakAdvectionOperator(c,
-        inflow_u=u_analytic(sym.nodes(dim, BTAG_ALL)),
-        flux_type=flux_type)
 
-    bound_op = bind(discr, op.sym_operator())
-    u = bind(discr, u_analytic(sym.nodes(dim)))(actx, t=0)
+    adv_operator = WeakAdvectionOperator(
+        dcoll,
+        c,
+        inflow_u=lambda t: u_analytic(
+            thaw(op.nodes(dcoll, dd=BTAG_ALL), actx),
+            t=t
+        ),
+        flux_type=flux_type
+    )
+
+    nodes = thaw(op.nodes(dcoll), actx)
+    u = u_analytic(nodes, t=0)
 
     def rhs(t, u):
-        return bound_op(t=t, u=u)
+        return adv_operator.operator(t, u)
 
     # }}}
 
@@ -161,11 +177,9 @@ def main(ctx_factory, dim=2, order=4, visualize=False):
 
     from grudge.shortcuts import set_up_rk4
     dt_stepper = set_up_rk4("u", dt, u, rhs)
-    plot = Plotter(actx, discr, order, visualize=visualize,
+    plot = Plotter(actx, dcoll, order, visualize=visualize,
             ylim=[-1.1, 1.1])
 
-    norm = bind(discr, sym.norm(2, sym.var("u")))
-
     step = 0
     norm_u = 0.0
     for event in dt_stepper.run(t_end=final_time):
@@ -173,12 +187,16 @@ def main(ctx_factory, dim=2, order=4, visualize=False):
             continue
 
         if step % 10 == 0:
-            norm_u = norm(u=event.state_component)
+            norm_u = op.norm(dcoll, event.state_component, 2)
             plot(event, "fld-weak-%04d" % step)
 
         step += 1
         logger.info("[%04d] t = %.5f |u| = %.5e", step, event.t, norm_u)
 
+        # NOTE: These are here to ensure the solution is bounded for the
+        # time interval specified
+        assert norm_u < 1
+
     # }}}
 
 
diff --git a/examples/geometry.py b/examples/geometry.py
index 2af1d8cec58edfd0b2654862e1312b1e4d670893..faf0fc42c1553ea764dbc1621142e21c9f3f4599 100644
--- a/examples/geometry.py
+++ b/examples/geometry.py
@@ -1,6 +1,9 @@
-"""Minimal example of a grudge driver."""
+"""Minimal example of viewing geometric quantities."""
 
-__copyright__ = "Copyright (C) 2015 Andreas Kloeckner"
+__copyright__ = """
+Copyright (C) 2015 Andreas Kloeckner
+Copyright (C) 2021 University of Illinois Board of Trustees
+"""
 
 __license__ = """
 Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -25,39 +28,40 @@ THE SOFTWARE.
 
 import numpy as np  # noqa
 import pyopencl as cl
-from grudge import sym, bind, DiscretizationCollection, shortcuts
+import pyopencl.tools as cl_tools
+
+from arraycontext import PyOpenCLArrayContext, thaw
 
-from meshmode.array_context import PyOpenCLArrayContext
+import grudge.op as op
+
+from grudge import DiscretizationCollection, shortcuts
 
 
 def main(write_output=True):
     cl_ctx = cl.create_some_context()
     queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+    actx = PyOpenCLArrayContext(
+        queue,
+        allocator=cl_tools.MemoryPool(cl_tools.ImmediateAllocator(queue))
+    )
 
     from meshmode.mesh import BTAG_ALL
     from meshmode.mesh.generation import generate_warped_rect_mesh
     mesh = generate_warped_rect_mesh(dim=2, order=4, nelements_side=6)
 
-    discr = DiscretizationCollection(actx, mesh, order=4)
-
-    sym_op = sym.normal(BTAG_ALL, mesh.dim)
-    # sym_op = sym.nodes(mesh.dim, dd=BTAG_ALL)
-    print(sym.pretty(sym_op))
-    op = bind(discr, sym_op)
-    print()
-    print(op.eval_code)
+    dcoll = DiscretizationCollection(actx, mesh, order=4)
 
-    vec = op(actx)
+    nodes = thaw(op.nodes(dcoll), actx)
+    bdry_nodes = thaw(op.nodes(dcoll, dd=BTAG_ALL), actx)
+    bdry_normals = thaw(op.normal(dcoll, dd=BTAG_ALL), actx)
 
-    vis = shortcuts.make_visualizer(discr)
-    vis.write_vtk_file("geo.vtu", [
-        ])
+    if write_output:
+        vis = shortcuts.make_visualizer(dcoll)
+        vis.write_vtk_file("geo.vtu", [("nodes", nodes)])
 
-    bvis = shortcuts.make_boundary_visualizer(discr)
-    bvis.write_vtk_file("bgeo.vtu", [
-        ("normals", vec)
-        ])
+        bvis = shortcuts.make_boundary_visualizer(dcoll)
+        bvis.write_vtk_file("bgeo.vtu", [("bdry normals", bdry_normals),
+                                         ("bdry nodes", bdry_nodes)])
 
 
 if __name__ == "__main__":
diff --git a/examples/maxwell/cavities.py b/examples/maxwell/cavities.py
index d82a5a5b6daf89b04ffccf6b92b0e9e5ea0ab7b8..57006e45d5c4499d3ea81bcea3781f93a457fa80 100644
--- a/examples/maxwell/cavities.py
+++ b/examples/maxwell/cavities.py
@@ -1,6 +1,9 @@
 """Minimal example of a grudge driver."""
 
-__copyright__ = "Copyright (C) 2015 Andreas Kloeckner"
+__copyright__ = """
+Copyright (C) 2015 Andreas Kloeckner
+Copyright (C) 2021 University of Illinois Board of Trustees
+"""
 
 __license__ = """
 Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -25,22 +28,28 @@ THE SOFTWARE.
 
 import numpy as np
 import pyopencl as cl
+import pyopencl.tools as cl_tools
 
-from meshmode.array_context import PyOpenCLArrayContext
+from arraycontext import PyOpenCLArrayContext, thaw
 
 from grudge.shortcuts import set_up_rk4
-from grudge import sym, bind, DiscretizationCollection
+from grudge import DiscretizationCollection
 
 from grudge.models.em import get_rectangular_cavity_mode
 
+import grudge.op as op
+
 
 STEPS = 60
 
 
-def main(dims, write_output=True, order=4):
+def main(dims, write_output=False, order=4):
     cl_ctx = cl.create_some_context()
     queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+    actx = PyOpenCLArrayContext(
+        queue,
+        allocator=cl_tools.MemoryPool(cl_tools.ImmediateAllocator(queue))
+    )
 
     from meshmode.mesh.generation import generate_regular_rect_mesh
     mesh = generate_regular_rect_mesh(
@@ -48,7 +57,7 @@ def main(dims, write_output=True, order=4):
             b=(1.0,)*dims,
             nelements_per_axis=(4,)*dims)
 
-    discr = DiscretizationCollection(actx, mesh, order=order)
+    dcoll = DiscretizationCollection(actx, mesh, order=order)
 
     if 0:
         epsilon0 = 8.8541878176e-12  # C**2 / (N m**2)
@@ -60,25 +69,30 @@ def main(dims, write_output=True, order=4):
         mu = 1
 
     from grudge.models.em import MaxwellOperator
-    op = MaxwellOperator(epsilon, mu, flux_type=0.5, dimensions=dims)
 
-    if dims == 3:
-        sym_mode = get_rectangular_cavity_mode(1, (1, 2, 2))
-        fields = bind(discr, sym_mode)(actx, t=0, epsilon=epsilon, mu=mu)
-    else:
-        sym_mode = get_rectangular_cavity_mode(1, (2, 3))
-        fields = bind(discr, sym_mode)(actx, t=0)
+    maxwell_operator = MaxwellOperator(
+        dcoll,
+        epsilon,
+        mu,
+        flux_type=0.5,
+        dimensions=dims
+    )
 
-    # FIXME
-    #dt = op.estimate_rk4_timestep(discr, fields=fields)
+    def cavity_mode(x, t=0):
+        if dims == 3:
+            return get_rectangular_cavity_mode(actx, x, t, 1, (1, 2, 2))
+        else:
+            return get_rectangular_cavity_mode(actx, x, t, 1, (2, 3))
 
-    op.check_bc_coverage(mesh)
+    fields = cavity_mode(thaw(op.nodes(dcoll), actx), t=0)
 
-    # print(sym.pretty(op.sym_operator()))
-    bound_op = bind(discr, op.sym_operator())
+    # FIXME
+    # dt = maxwell_operator.estimate_rk4_timestep(dcoll, fields=fields)
+
+    maxwell_operator.check_bc_coverage(mesh)
 
     def rhs(t, w):
-        return bound_op(t=t, w=w)
+        return maxwell_operator.operator(t, w)
 
     if mesh.dim == 2:
         dt = 0.004
@@ -93,23 +107,26 @@ def main(dims, write_output=True, order=4):
     print("dt=%g nsteps=%d" % (dt, nsteps))
 
     from grudge.shortcuts import make_visualizer
-    vis = make_visualizer(discr)
+    vis = make_visualizer(dcoll)
 
     step = 0
 
-    norm = bind(discr, sym.norm(2, sym.var("u")))
+    def norm(u):
+        return op.norm(dcoll, u, 2)
 
     from time import time
     t_last_step = time()
 
-    e, h = op.split_eh(fields)
+    e, h = maxwell_operator.split_eh(fields)
 
-    if 1:
-        vis.write_vtk_file("fld-cavities-%04d.vtu" % step,
-                [
-                    ("e", e),
-                    ("h", h),
-                    ])
+    if write_output:
+        vis.write_vtk_file(
+            f"fld-cavities-{step:04d}.vtu",
+            [
+                ("e", e),
+                ("h", h),
+            ]
+        )
 
     for event in dt_stepper.run(t_end=final_t):
         if isinstance(event, dt_stepper.StateComputed):
@@ -117,18 +134,32 @@ def main(dims, write_output=True, order=4):
 
             step += 1
 
-            print(step, event.t, norm(u=e[0]), norm(u=e[1]),
-                    norm(u=h[0]), norm(u=h[1]),
-                    time()-t_last_step)
+            norm_e0 = norm(u=e[0])
+            norm_e1 = norm(u=e[1])
+            norm_h0 = norm(u=h[0])
+            norm_h1 = norm(u=h[1])
+            print(step, event.t,
+                  norm_e0, norm_e1, norm_h0, norm_h1,
+                  time()-t_last_step)
             if step % 10 == 0:
-                e, h = op.split_eh(event.state_component)
-                vis.write_vtk_file("fld-cavities-%04d.vtu" % step,
+                if write_output:
+                    e, h = maxwell_operator.split_eh(event.state_component)
+                    vis.write_vtk_file(
+                        f"fld-cavities-{step:04d}.vtu",
                         [
                             ("e", e),
                             ("h", h),
-                            ])
+                        ]
+                    )
             t_last_step = time()
 
+            # NOTE: These are here to ensure the solution is bounded for the
+            # time interval specified
+            assert norm_e0 < 0.5
+            assert norm_e1 < 0.5
+            assert norm_h0 < 0.5
+            assert norm_h1 < 0.5
+
 
 if __name__ == "__main__":
     main(3)
diff --git a/examples/dagrt-fusion.py b/examples/old_symbolics/dagrt-fusion.py
similarity index 97%
rename from examples/dagrt-fusion.py
rename to examples/old_symbolics/dagrt-fusion.py
index bd399c3a38542280f49cb66b00d6979541085a31..ea6cb6bd6638f3da69a4b2ff3eb82115fb695c28 100755
--- a/examples/dagrt-fusion.py
+++ b/examples/old_symbolics/dagrt-fusion.py
@@ -54,13 +54,15 @@ import os
 import sys
 import pyopencl as cl
 import pyopencl.array  # noqa
+import pyopencl.tools as cl_tools
 import pytest
 
 import dagrt.language as lang
 import pymbolic.primitives as p
 
+from arraycontext import PyOpenCLArrayContext
+
 from meshmode.dof_array import DOFArray
-from meshmode.array_context import PyOpenCLArrayContext
 
 import grudge.dof_desc as dof_desc
 import grudge.symbolic.mappers as gmap
@@ -461,7 +463,7 @@ def get_wave_op_with_discr(actx, dims=2, order=4):
 
     discr = DiscretizationCollection(actx, mesh, order=order)
 
-    from grudge.models.wave import WeakWaveOperator
+    from symbolic_wave_op import WeakWaveOperator
     from meshmode.mesh import BTAG_ALL, BTAG_NONE
     op = WeakWaveOperator(0.1, dims,
             source_f=_get_source_term(dims),
@@ -486,7 +488,10 @@ def get_wave_component(state_component):
 def test_stepper_equivalence(ctx_factory, order=4):
     cl_ctx = ctx_factory()
     queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+    actx = PyOpenCLArrayContext(
+        queue,
+        allocator=cl_tools.MemoryPool(cl_tools.ImmediateAllocator(queue))
+    )
 
     dims = 2
 
@@ -750,7 +755,10 @@ class ExecutionMapperWithMemOpCounting(ExecutionMapperWrapper):
 def test_assignment_memory_model(ctx_factory):
     cl_ctx = ctx_factory()
     queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+    actx = PyOpenCLArrayContext(
+        queue,
+        allocator=cl_tools.MemoryPool(cl_tools.ImmediateAllocator(queue))
+    )
 
     _, discr = get_wave_op_with_discr(actx, dims=2, order=3)
 
@@ -778,7 +786,10 @@ def test_assignment_memory_model(ctx_factory):
 def test_stepper_mem_ops(ctx_factory, use_fusion):
     cl_ctx = ctx_factory()
     queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+    actx = PyOpenCLArrayContext(
+        queue,
+        allocator=cl_tools.MemoryPool(cl_tools.ImmediateAllocator(queue))
+    )
 
     dims = 2
 
@@ -949,7 +960,10 @@ def test_stepper_timing(ctx_factory, use_fusion):
     queue = cl.CommandQueue(
             cl_ctx,
             properties=cl.command_queue_properties.PROFILING_ENABLE)
-    actx = PyOpenCLArrayContext(queue)
+    actx = PyOpenCLArrayContext(
+        queue,
+        allocator=cl_tools.MemoryPool(cl_tools.ImmediateAllocator(queue))
+    )
 
     dims = 3
 
@@ -1072,7 +1086,10 @@ else:
 def problem_stats(order=3):
     cl_ctx = cl.create_some_context()
     queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+    actx = PyOpenCLArrayContext(
+        queue,
+        allocator=cl_tools.MemoryPool(cl_tools.ImmediateAllocator(queue))
+    )
 
     with open_output_file("grudge-problem-stats.txt") as outf:
         _, dg_discr_2d = get_wave_op_with_discr(
@@ -1097,7 +1114,10 @@ def problem_stats(order=3):
 def statement_counts_table():
     cl_ctx = cl.create_some_context()
     queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+    actx = PyOpenCLArrayContext(
+        queue,
+        allocator=cl_tools.MemoryPool(cl_tools.ImmediateAllocator(queue))
+    )
 
     fused_stepper = get_example_stepper(actx, use_fusion=True)
     stepper = get_example_stepper(actx, use_fusion=False)
@@ -1188,7 +1208,10 @@ def mem_ops_results(actx, dims):
 def scalar_assignment_percent_of_total_mem_ops_table():
     cl_ctx = cl.create_some_context()
     queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+    actx = PyOpenCLArrayContext(
+        queue,
+        allocator=cl_tools.MemoryPool(cl_tools.ImmediateAllocator(queue))
+    )
 
     result2d = mem_ops_results(actx, 2)
     result3d = mem_ops_results(actx, 3)
diff --git a/examples/old_symbolics/symbolic_wave_op.py b/examples/old_symbolics/symbolic_wave_op.py
new file mode 100644
index 0000000000000000000000000000000000000000..466c92a37c00e8639526eb4e1f9b5ad577123fd1
--- /dev/null
+++ b/examples/old_symbolics/symbolic_wave_op.py
@@ -0,0 +1,166 @@
+"""(Old world) symbolic wave equation operator."""
+
+__copyright__ = "Copyright (C) 2009 Andreas Kloeckner"
+
+__license__ = """
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+"""
+
+import numpy as np
+from meshmode.mesh import BTAG_ALL, BTAG_NONE
+from grudge import sym
+from pytools.obj_array import flat_obj_array
+
+
+# {{{ Old-world symbolic wave operator
+
+class WeakWaveOperator:
+    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)
+
+        central_flux_weak = -self.c*flat_obj_array(
+                np.dot(v.avg, normal),
+                u.avg * normal)
+
+        if self.flux_type == "central":
+            return central_flux_weak
+        elif self.flux_type == "upwind":
+            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)
+
+    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.var("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 -----------------------------------------------------
+        def flux(pair):
+            return sym.project(pair.dd, "all_faces")(self.flux(pair))
+
+        result = sym.InverseMassOperator()(
+                flat_obj_array(
+                    -self.c*np.dot(sym.stiffness_t(self.ambient_dim), v),
+                    -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))
+                    + 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])
+
+# }}}
+
+
+# vim: foldmethod=marker
diff --git a/examples/wave/var-propagation-speed.py b/examples/wave/var-propagation-speed.py
index 5fb31617a2787de8f8334f709cf49f7a75f7d709..a1a211c923e1726ae80fcaee6cc584014585fb8f 100644
--- a/examples/wave/var-propagation-speed.py
+++ b/examples/wave/var-propagation-speed.py
@@ -1,6 +1,9 @@
 """Minimal example of a grudge driver."""
 
-__copyright__ = "Copyright (C) 2015 Andreas Kloeckner"
+__copyright__ = """
+Copyright (C) 2015 Andreas Kloeckner
+Copyright (C) 2021 University of Illinois Board of Trustees
+"""
 
 __license__ = """
 Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -25,16 +28,25 @@ THE SOFTWARE.
 
 import numpy as np
 import pyopencl as cl
+import pyopencl.tools as cl_tools
+
+from arraycontext import PyOpenCLArrayContext, thaw
+
 from grudge.shortcuts import set_up_rk4
-from grudge import sym, bind, DiscretizationCollection
+from grudge import DiscretizationCollection
 
-from meshmode.array_context import PyOpenCLArrayContext
+from pytools.obj_array import flat_obj_array
 
+import grudge.op as op
 
-def main(write_output=True, order=4):
+
+def main(write_output=False, order=4):
     cl_ctx = cl.create_some_context()
     queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+    actx = PyOpenCLArrayContext(
+        queue,
+        allocator=cl_tools.MemoryPool(cl_tools.ImmediateAllocator(queue))
+    )
 
     dims = 2
     from meshmode.mesh.generation import generate_regular_rect_mesh
@@ -43,45 +55,50 @@ def main(write_output=True, order=4):
             b=(0.5,)*dims,
             nelements_per_axis=(20,)*dims)
 
-    discr = DiscretizationCollection(actx, mesh, order=order)
-
-    source_center = np.array([0.1, 0.22, 0.33])[:mesh.dim]
-    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")
-    c = sym.If(sym.Comparison(
-                np.dot(sym_x, sym_x), "<", 0.15),
-                np.float32(0.1), np.float32(0.2))
+    dcoll = DiscretizationCollection(actx, mesh, order=order)
+
+    def source_f(actx, dcoll, t=0):
+        source_center = np.array([0.1, 0.22, 0.33])[:dcoll.dim]
+        source_width = 0.05
+        source_omega = 3
+        nodes = thaw(op.nodes(dcoll), actx)
+        source_center_dist = flat_obj_array(
+            [nodes[i] - source_center[i] for i in range(dcoll.dim)]
+        )
+        return (
+            np.sin(source_omega*t)
+            * actx.np.exp(
+                -np.dot(source_center_dist, source_center_dist)
+                / source_width**2
+            )
+        )
+
+    x = thaw(op.nodes(dcoll), actx)
+    ones = dcoll.zeros(actx) + 1
+    c = actx.np.where(np.dot(x, x) < 0.15, 0.1 * ones, 0.2 * ones)
 
     from grudge.models.wave import VariableCoefficientWeakWaveOperator
     from meshmode.mesh import BTAG_ALL, BTAG_NONE
-    op = VariableCoefficientWeakWaveOperator(c,
-            discr.dim,
-            source_f=(
-                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")
-
-    from pytools.obj_array import flat_obj_array
-    fields = flat_obj_array(discr.zeros(actx),
-            [discr.zeros(actx) for i in range(discr.dim)])
 
-    op.check_bc_coverage(mesh)
+    wave_op = VariableCoefficientWeakWaveOperator(
+        dcoll,
+        c,
+        source_f=source_f,
+        dirichlet_tag=BTAG_NONE,
+        neumann_tag=BTAG_NONE,
+        radiation_tag=BTAG_ALL,
+        flux_type="upwind"
+    )
 
-    c_eval = bind(discr, c)(actx)
+    fields = flat_obj_array(
+        dcoll.zeros(actx),
+        [dcoll.zeros(actx) for i in range(dcoll.dim)]
+    )
 
-    bound_op = bind(discr, op.sym_operator())
+    wave_op.check_bc_coverage(mesh)
 
     def rhs(t, w):
-        return bound_op(t=t, w=w)
+        return wave_op.operator(t, w)
 
     if mesh.dim == 2:
         dt = 0.04 * 0.3
@@ -95,15 +112,28 @@ def main(write_output=True, order=4):
     print("dt=%g nsteps=%d" % (dt, nsteps))
 
     from grudge.shortcuts import make_visualizer
-    vis = make_visualizer(discr)
+    vis = make_visualizer(dcoll)
 
     step = 0
 
-    norm = bind(discr, sym.norm(2, sym.var("u")))
+    def norm(u):
+        return op.norm(dcoll, u, 2)
 
     from time import time
     t_last_step = time()
 
+    if write_output:
+        u = fields[0]
+        v = fields[1:]
+        vis.write_vtk_file(
+            f"fld-var-propogation-speed-{step:04d}.vtu",
+            [
+                ("u", u),
+                ("v", v),
+                ("c", c),
+            ]
+        )
+
     for event in dt_stepper.run(t_end=final_t):
         if isinstance(event, dt_stepper.StateComputed):
             assert event.component_id == "w"
@@ -113,13 +143,19 @@ def main(write_output=True, order=4):
             if step % 10 == 0:
                 print(f"step: {step} t: {time()-t_last_step} "
                       f"L2: {norm(u=event.state_component[0])}")
-                vis.write_vtk_file("fld-var-propogation-speed-%04d.vtu" % step,
+                if write_output:
+                    vis.write_vtk_file(
+                        f"fld-var-propogation-speed-{step:04d}.vtu",
                         [
                             ("u", event.state_component[0]),
                             ("v", event.state_component[1:]),
-                            ("c", c_eval),
-                            ])
+                            ("c", c),
+                        ]
+                    )
             t_last_step = time()
+
+            # NOTE: These are here to ensure the solution is bounded for the
+            # time interval specified
             assert norm(u=event.state_component[0]) < 1
 
 
diff --git a/examples/wave/wave-min-mpi.py b/examples/wave/wave-min-mpi.py
index 29f59dfff8342227638c6bdc503f7ad79d3251ed..1fb3eb9eaafb19bf1e576b9408985a609f8db467 100644
--- a/examples/wave/wave-min-mpi.py
+++ b/examples/wave/wave-min-mpi.py
@@ -1,6 +1,9 @@
 """Minimal example of a grudge driver."""
 
-__copyright__ = "Copyright (C) 2015 Andreas Kloeckner"
+__copyright__ = """
+Copyright (C) 2015 Andreas Kloeckner
+Copyright (C) 2021 University of Illinois Board of Trustees
+"""
 
 __license__ = """
 Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -25,16 +28,27 @@ THE SOFTWARE.
 
 import numpy as np
 import pyopencl as cl
-from meshmode.array_context import PyOpenCLArrayContext
+import pyopencl.tools as cl_tools
+
+from arraycontext import PyOpenCLArrayContext, thaw
+
 from grudge.shortcuts import set_up_rk4
-from grudge import sym, bind, DiscretizationCollection
+from grudge import DiscretizationCollection
+
 from mpi4py import MPI
 
+from pytools.obj_array import flat_obj_array
 
-def main(write_output=True, order=4):
+import grudge.op as op
+
+
+def main(write_output=False, order=4):
     cl_ctx = cl.create_some_context()
     queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+    actx = PyOpenCLArrayContext(
+        queue,
+        allocator=cl_tools.MemoryPool(cl_tools.ImmediateAllocator(queue))
+    )
 
     comm = MPI.COMM_WORLD
     num_parts = comm.Get_size()
@@ -61,7 +75,7 @@ def main(write_output=True, order=4):
     else:
         local_mesh = mesh_dist.receive_mesh_part()
 
-    discr = DiscretizationCollection(actx, local_mesh, order=order,
+    dcoll = DiscretizationCollection(actx, local_mesh, order=order,
             mpi_communicator=comm)
 
     if local_mesh.dim == 2:
@@ -69,59 +83,79 @@ def main(write_output=True, order=4):
     elif local_mesh.dim == 3:
         dt = 0.02
 
-    source_center = np.array([0.1, 0.22, 0.33])[:local_mesh.dim]
-    source_width = 0.05
-    source_omega = 3
-
-    sym_x = sym.nodes(local_mesh.dim)
-    sym_source_center_dist = sym_x - source_center
-    sym_t = sym.ScalarVariable("t")
+    def source_f(actx, dcoll, t=0):
+        source_center = np.array([0.1, 0.22, 0.33])[:dcoll.dim]
+        source_width = 0.05
+        source_omega = 3
+        nodes = thaw(op.nodes(dcoll), actx)
+        source_center_dist = flat_obj_array(
+            [nodes[i] - source_center[i] for i in range(dcoll.dim)]
+        )
+        return (
+            np.sin(source_omega*t)
+            * actx.np.exp(
+                -np.dot(source_center_dist, source_center_dist)
+                / source_width**2
+            )
+        )
 
     from grudge.models.wave import WeakWaveOperator
     from meshmode.mesh import BTAG_ALL, BTAG_NONE
-    op = WeakWaveOperator(0.1, discr.dim,
-            source_f=(
-                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")
-
-    from pytools.obj_array import flat_obj_array
+
+    wave_op = WeakWaveOperator(
+        dcoll,
+        0.1,
+        source_f=source_f,
+        dirichlet_tag=BTAG_NONE,
+        neumann_tag=BTAG_NONE,
+        radiation_tag=BTAG_ALL,
+        flux_type="upwind"
+    )
+
     fields = flat_obj_array(
-            discr.zeros(actx),
-            [discr.zeros(actx) for i in range(discr.dim)])
+        dcoll.zeros(actx),
+        [dcoll.zeros(actx) for i in range(dcoll.dim)]
+    )
 
     # FIXME
-    #dt = op.estimate_rk4_timestep(discr, fields=fields)
-
-    op.check_bc_coverage(local_mesh)
+    # dt = wave_op.estimate_rk4_timestep(dcoll, fields=fields)
 
-    # print(sym.pretty(op.sym_operator()))
-    bound_op = bind(discr, op.sym_operator())
+    wave_op.check_bc_coverage(local_mesh)
 
     def rhs(t, w):
-        return bound_op(t=t, w=w)
+        return wave_op.operator(t, w)
 
     dt_stepper = set_up_rk4("w", dt, fields, rhs)
 
     final_t = 10
     nsteps = int(final_t/dt)
-    print("dt=%g nsteps=%d" % (dt, nsteps))
+
+    if comm.rank == 0:
+        print("dt=%g nsteps=%d" % (dt, nsteps))
 
     from grudge.shortcuts import make_visualizer
-    vis = make_visualizer(discr)
+    vis = make_visualizer(dcoll)
 
     step = 0
 
-    norm = bind(discr, sym.norm(2, sym.var("u")))
+    def norm(u):
+        return op.norm(dcoll, u, 2)
 
     from time import time
     t_last_step = time()
 
+    if write_output:
+        u = fields[0]
+        v = fields[1:]
+        vis.write_parallel_vtk_file(
+            comm,
+            f"fld-wave-min-mpi-{{rank:03d}}-{step:04d}.vtu",
+            [
+                ("u", u),
+                ("v", v),
+            ]
+        )
+
     for event in dt_stepper.run(t_end=final_t):
         if isinstance(event, dt_stepper.StateComputed):
             assert event.component_id == "w"
@@ -129,17 +163,21 @@ def main(write_output=True, order=4):
             step += 1
 
             if step % 10 == 0:
-                if comm.rank == 0:
-                    print(f"step: {step} t: {time()-t_last_step} "
-                          f"L2: {norm(u=event.state_component[0])}")
-                vis.write_parallel_vtk_file(
+                print(f"step: {step} t: {time()-t_last_step} "
+                        f"L2: {norm(u=event.state_component[0])}")
+                if write_output:
+                    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:]),
-                            ])
+                        ]
+                    )
             t_last_step = time()
+
+            # NOTE: These are here to ensure the solution is bounded for the
+            # time interval specified
             assert norm(u=event.state_component[0]) < 1
 
 
diff --git a/examples/wave/wave-min.py b/examples/wave/wave-min.py
index bd306b0fbed822cf428504912b8e024965888150..a9947483b8bb8918c31972ebb519c9d9fedf613e 100644
--- a/examples/wave/wave-min.py
+++ b/examples/wave/wave-min.py
@@ -1,7 +1,9 @@
 """Minimal example of a grudge driver."""
 
-
-__copyright__ = "Copyright (C) 2015 Andreas Kloeckner"
+__copyright__ = """
+Copyright (C) 2015 Andreas Kloeckner
+Copyright (C) 2021 University of Illinois Board of Trustees
+"""
 
 __license__ = """
 Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -26,15 +28,25 @@ THE SOFTWARE.
 
 import numpy as np
 import pyopencl as cl
-from meshmode.array_context import PyOpenCLArrayContext
+import pyopencl.tools as cl_tools
+
+from arraycontext import PyOpenCLArrayContext, thaw
+
 from grudge.shortcuts import set_up_rk4
-from grudge import sym, bind, DiscretizationCollection
+from grudge import DiscretizationCollection
+
+from pytools.obj_array import flat_obj_array
+
+import grudge.op as op
 
 
-def main(write_output=True, order=4):
+def main(write_output=False, order=4):
     cl_ctx = cl.create_some_context()
     queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+    actx = PyOpenCLArrayContext(
+        queue,
+        allocator=cl_tools.MemoryPool(cl_tools.ImmediateAllocator(queue))
+    )
 
     dims = 2
     from meshmode.mesh.generation import generate_regular_rect_mesh
@@ -50,43 +62,49 @@ def main(write_output=True, order=4):
 
     print("%d elements" % mesh.nelements)
 
-    discr = DiscretizationCollection(actx, mesh, order=order)
-
-    source_center = np.array([0.1, 0.22, 0.33])[:mesh.dim]
-    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")
+    dcoll = DiscretizationCollection(actx, mesh, order=order)
+
+    def source_f(actx, dcoll, t=0):
+        source_center = np.array([0.1, 0.22, 0.33])[:dcoll.dim]
+        source_width = 0.05
+        source_omega = 3
+        nodes = thaw(op.nodes(dcoll), actx)
+        source_center_dist = flat_obj_array(
+            [nodes[i] - source_center[i] for i in range(dcoll.dim)]
+        )
+        return (
+            np.sin(source_omega*t)
+            * actx.np.exp(
+                -np.dot(source_center_dist, source_center_dist)
+                / source_width**2
+            )
+        )
 
     from grudge.models.wave import WeakWaveOperator
     from meshmode.mesh import BTAG_ALL, BTAG_NONE
-    op = WeakWaveOperator(0.1, discr.dim,
-            source_f=(
-                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")
-
-    from pytools.obj_array import flat_obj_array
-    fields = flat_obj_array(discr.zeros(actx),
-            [discr.zeros(actx) for i in range(discr.dim)])
 
-    # FIXME
-    #dt = op.estimate_rk4_timestep(discr, fields=fields)
+    wave_op = WeakWaveOperator(
+        dcoll,
+        0.1,
+        source_f=source_f,
+        dirichlet_tag=BTAG_NONE,
+        neumann_tag=BTAG_NONE,
+        radiation_tag=BTAG_ALL,
+        flux_type="upwind"
+    )
+
+    fields = flat_obj_array(
+        dcoll.zeros(actx),
+        [dcoll.zeros(actx) for i in range(dcoll.dim)]
+    )
 
-    op.check_bc_coverage(mesh)
+    # FIXME
+    # dt = wave_op.estimate_rk4_timestep(dcoll, fields=fields)
 
-    # print(sym.pretty(op.sym_operator()))
-    bound_op = bind(discr, op.sym_operator())
+    wave_op.check_bc_coverage(mesh)
 
     def rhs(t, w):
-        return bound_op(t=t, w=w)
+        return wave_op.operator(t, w)
 
     dt_stepper = set_up_rk4("w", dt, fields, rhs)
 
@@ -95,15 +113,27 @@ def main(write_output=True, order=4):
     print("dt=%g nsteps=%d" % (dt, nsteps))
 
     from grudge.shortcuts import make_visualizer
-    vis = make_visualizer(discr)
+    vis = make_visualizer(dcoll)
 
     step = 0
 
-    norm = bind(discr, sym.norm(2, sym.var("u")))
+    def norm(u):
+        return op.norm(dcoll, u, 2)
 
     from time import time
     t_last_step = time()
 
+    if write_output:
+        u = fields[0]
+        v = fields[1:]
+        vis.write_vtk_file(
+            f"fld-wave-min-{step:04d}.vtu",
+            [
+                ("u", u),
+                ("v", v),
+            ]
+        )
+
     for event in dt_stepper.run(t_end=final_t):
         if isinstance(event, dt_stepper.StateComputed):
             assert event.component_id == "w"
@@ -113,12 +143,18 @@ def main(write_output=True, order=4):
             if step % 10 == 0:
                 print(f"step: {step} t: {time()-t_last_step} "
                       f"L2: {norm(u=event.state_component[0])}")
-                vis.write_vtk_file("fld-wave-min-%04d.vtu" % step,
+                if write_output:
+                    vis.write_vtk_file(
+                        f"fld-wave-min-{step:04d}.vtu",
                         [
                             ("u", event.state_component[0]),
                             ("v", event.state_component[1:]),
-                            ])
+                        ]
+                    )
             t_last_step = time()
+
+            # NOTE: These are here to ensure the solution is bounded for the
+            # time interval specified
             assert norm(u=event.state_component[0]) < 1
 
 
diff --git a/examples/wave/wave-op-mpi.py b/examples/wave/wave-op-mpi.py
index b6806b9d3375c4a2319366643bf9de431cf6c5e1..55253d17e04a723cbcec75e39b54856e46967aa5 100644
--- a/examples/wave/wave-op-mpi.py
+++ b/examples/wave/wave-op-mpi.py
@@ -1,4 +1,9 @@
-__copyright__ = "Copyright (C) 2020 Andreas Kloeckner"
+"""Minimal example of a grudge driver."""
+
+__copyright__ = """
+Copyright (C) 2020 Andreas Kloeckner
+Copyright (C) 2021 University of Illinois Board of Trustees
+"""
 
 __license__ = """
 Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -24,18 +29,19 @@ THE SOFTWARE.
 import numpy as np
 import numpy.linalg as la  # noqa
 import pyopencl as cl
+import pyopencl.tools as cl_tools
 
-from pytools.obj_array import flat_obj_array
+from arraycontext import PyOpenCLArrayContext, thaw
 
-from meshmode.array_context import PyOpenCLArrayContext
-from meshmode.dof_array import thaw
+from pytools.obj_array import flat_obj_array
 
 from meshmode.mesh import BTAG_ALL, BTAG_NONE  # noqa
 
 from grudge.discretization import DiscretizationCollection
-import grudge.op as op
 from grudge.shortcuts import make_visualizer
-from grudge.symbolic.primitives import TracePair
+
+import grudge.op as op
+
 from mpi4py import MPI
 
 
@@ -45,7 +51,7 @@ def wave_flux(dcoll, c, w_tpair):
     u = w_tpair[0]
     v = w_tpair[1:]
 
-    normal = thaw(u.int.array_context, op.normal(dcoll, w_tpair.dd))
+    normal = thaw(op.normal(dcoll, w_tpair.dd), u.int.array_context)
 
     flux_weak = flat_obj_array(
             np.dot(v.avg, normal),
@@ -72,22 +78,27 @@ def wave_operator(dcoll, c, w):
     dir_bc = flat_obj_array(-dir_u, dir_v)
 
     return (
-            op.inverse_mass(dcoll,
-                flat_obj_array(
-                    -c*op.weak_local_div(dcoll, v),
-                    -c*op.weak_local_grad(dcoll, u)
-                    )
-                +  # noqa: W504
-                op.face_mass(dcoll,
-                    wave_flux(dcoll, c=c, w_tpair=op.interior_trace_pair(dcoll, w))
-                    + wave_flux(dcoll, c=c, w_tpair=TracePair(
-                        BTAG_ALL, interior=dir_bval, exterior=dir_bc))
-                    + sum(
-                        wave_flux(dcoll, c=c, w_tpair=tpair)
-                        for tpair in op.cross_rank_trace_pairs(dcoll, w))
-                    )
-                )
+        op.inverse_mass(
+            dcoll,
+            flat_obj_array(
+                -c*op.weak_local_div(dcoll, v),
+                -c*op.weak_local_grad(dcoll, u)
+            )
+            + op.face_mass(
+                dcoll,
+                wave_flux(
+                    dcoll, c=c,
+                    w_tpair=op.bdry_trace_pair(dcoll,
+                                               BTAG_ALL,
+                                               interior=dir_bval,
+                                               exterior=dir_bc)
+                ) + sum(
+                    wave_flux(dcoll, c=c, w_tpair=tpair)
+                    for tpair in op.interior_trace_pairs(dcoll, w)
                 )
+            )
+        )
+    )
 
 # }}}
 
@@ -105,7 +116,7 @@ def bump(actx, dcoll, t=0):
     source_width = 0.05
     source_omega = 3
 
-    nodes = thaw(actx, op.nodes(dcoll))
+    nodes = thaw(op.nodes(dcoll), actx)
     center_dist = flat_obj_array([
         nodes[i] - source_center[i]
         for i in range(dcoll.dim)
@@ -118,10 +129,13 @@ def bump(actx, dcoll, t=0):
             / source_width**2))
 
 
-def main():
+def main(write_output=False):
     cl_ctx = cl.create_some_context()
     queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+    actx = PyOpenCLArrayContext(
+        queue,
+        allocator=cl_tools.MemoryPool(cl_tools.ImmediateAllocator(queue))
+    )
 
     comm = MPI.COMM_WORLD
     num_parts = comm.Get_size()
@@ -182,19 +196,26 @@ def main():
 
         if istep % 10 == 0:
             if comm.rank == 0:
-                print(f"step: {istep} t: {t} L2: {op.norm(dcoll, fields[0], 2)} "
-                      f"sol max: {op.nodal_max(dcoll, 'vol', fields[0])}")
-            vis.write_parallel_vtk_file(
+                print(f"step: {istep} t: {t} "
+                      f"L2: {op.norm(dcoll, fields[0], 2)} "
+                      f"Linf: {op.norm(dcoll, fields[0], np.inf)} "
+                      f"sol max: {op.nodal_max(dcoll, 'vol', fields[0])} "
+                      f"sol min: {op.nodal_min(dcoll, 'vol', fields[0])}")
+            if write_output:
+                vis.write_parallel_vtk_file(
                     comm,
                     f"fld-wave-eager-mpi-{{rank:03d}}-{istep:04d}.vtu",
                     [
                         ("u", fields[0]),
                         ("v", fields[1:]),
-                        ])
+                    ]
+                )
 
         t += dt
         istep += 1
 
+        # NOTE: These are here to ensure the solution is bounded for the
+        # time interval specified
         assert op.norm(dcoll, fields[0], 2) < 1
 
 
diff --git a/examples/wave/wave-op-var-velocity.py b/examples/wave/wave-op-var-velocity.py
index 6b7bed919e2d76fcb7f9465f84fec0efa3cd8e4f..7f842b809484444b64afe2b1aa7d0262e62dfcf5 100644
--- a/examples/wave/wave-op-var-velocity.py
+++ b/examples/wave/wave-op-var-velocity.py
@@ -1,4 +1,9 @@
-__copyright__ = "Copyright (C) 2020 Andreas Kloeckner"
+"""Minimal example of a grudge driver."""
+
+__copyright__ = """
+Copyright (C) 2020 Andreas Kloeckner
+Copyright (C) 2021 University of Illinois Board of Trustees
+"""
 
 __license__ = """
 Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -24,19 +29,19 @@ THE SOFTWARE.
 import numpy as np
 import numpy.linalg as la  # noqa
 import pyopencl as cl
+import pyopencl.tools as cl_tools
 
-from pytools.obj_array import flat_obj_array
+from arraycontext import PyOpenCLArrayContext, thaw
 
-from meshmode.array_context import PyOpenCLArrayContext
-from meshmode.dof_array import thaw
+from pytools.obj_array import flat_obj_array
 
 from meshmode.mesh import BTAG_ALL, BTAG_NONE  # noqa
 
 from grudge.discretization import DiscretizationCollection
 from grudge.dof_desc import DISCR_TAG_BASE, DISCR_TAG_QUAD, DOFDesc
-import grudge.op as op
 from grudge.shortcuts import make_visualizer
-from grudge.symbolic.primitives import TracePair
+
+import grudge.op as op
 
 
 # {{{ wave equation bits
@@ -48,7 +53,7 @@ def wave_flux(dcoll, c, w_tpair):
     u = w_tpair[0]
     v = w_tpair[1:]
 
-    normal = thaw(u.int.array_context, op.normal(dcoll, dd))
+    normal = thaw(op.normal(dcoll, dd), u.int.array_context)
 
     flux_weak = flat_obj_array(
             np.dot(v.avg, normal),
@@ -87,20 +92,28 @@ def wave_operator(dcoll, c, w):
     dd_allfaces_quad = DOFDesc("all_faces", DISCR_TAG_QUAD)
 
     return (
-            op.inverse_mass(dcoll,
-                flat_obj_array(
-                    -op.weak_local_div(dcoll, dd_quad, c_quad*v_quad),
-                    -op.weak_local_grad(dcoll, dd_quad, c_quad*u_quad) \
-                    # pylint: disable=E1130
-                    )
-                +  # noqa: W504
-                op.face_mass(dcoll,
-                    dd_allfaces_quad,
-                    wave_flux(dcoll, c=c, w_tpair=op.interior_trace_pair(dcoll, w))
-                    + wave_flux(dcoll, c=c, w_tpair=TracePair(
-                        BTAG_ALL, interior=dir_bval, exterior=dir_bc))
-                    ))
+        op.inverse_mass(
+            dcoll,
+            flat_obj_array(
+                -op.weak_local_div(dcoll, dd_quad, c_quad*v_quad),
+                -op.weak_local_grad(dcoll, dd_quad, c_quad*u_quad) \
+                # pylint: disable=invalid-unary-operand-type
+            ) + op.face_mass(
+                dcoll,
+                dd_allfaces_quad,
+                wave_flux(
+                    dcoll, c=c,
+                    w_tpair=op.bdry_trace_pair(dcoll,
+                                               BTAG_ALL,
+                                               interior=dir_bval,
+                                               exterior=dir_bc)
+                ) + sum(
+                    wave_flux(dcoll, c=c, w_tpair=tpair)
+                    for tpair in op.interior_trace_pairs(dcoll, w)
                 )
+            )
+        )
+    )
 
 # }}}
 
@@ -120,7 +133,7 @@ def bump(actx, dcoll, t=0, width=0.05, center=None):
     center = center[:dcoll.dim]
     source_omega = 3
 
-    nodes = thaw(actx, op.nodes(dcoll))
+    nodes = thaw(op.nodes(dcoll), actx)
     center_dist = flat_obj_array([
         nodes[i] - center[i]
         for i in range(dcoll.dim)
@@ -133,10 +146,13 @@ def bump(actx, dcoll, t=0, width=0.05, center=None):
             / width**2))
 
 
-def main():
+def main(write_output=False):
     cl_ctx = cl.create_some_context()
     queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+    actx = PyOpenCLArrayContext(
+        queue,
+        allocator=cl_tools.MemoryPool(cl_tools.ImmediateAllocator(queue))
+    )
 
     dim = 2
     nel_1d = 16
@@ -190,18 +206,26 @@ def main():
         fields = rk4_step(fields, t, dt, rhs)
 
         if istep % 10 == 0:
-            print(f"step: {istep} t: {t} L2: {op.norm(dcoll, fields[0], 2)} "
-                  f"sol max: {op.nodal_max(dcoll, 'vol', fields[0])}")
-            vis.write_vtk_file("fld-wave-eager-var-velocity-%04d.vtu" % istep,
+            print(f"step: {istep} t: {t} "
+                  f"L2: {op.norm(dcoll, fields[0], 2)} "
+                  f"Linf: {op.norm(dcoll, fields[0], np.inf)} "
+                  f"sol max: {op.nodal_max(dcoll, 'vol', fields[0])} "
+                  f"sol min: {op.nodal_min(dcoll, 'vol', fields[0])}")
+            if write_output:
+                vis.write_vtk_file(
+                    f"fld-wave-eager-var-velocity-{istep:04d}.vtu",
                     [
                         ("c", c),
                         ("u", fields[0]),
                         ("v", fields[1:]),
-                        ])
+                    ]
+                )
 
         t += dt
         istep += 1
 
+        # NOTE: These are here to ensure the solution is bounded for the
+        # time interval specified
         assert op.norm(dcoll, fields[0], 2) < 1
 
 
diff --git a/examples/wave/wave-op.py b/examples/wave/wave-op.py
index f8ef5785bb88b7b1b31adc0e4f81a4938d2187f7..0a669a2270b31202f398b487e03ad96d25fe6f26 100644
--- a/examples/wave/wave-op.py
+++ b/examples/wave/wave-op.py
@@ -1,4 +1,9 @@
-__copyright__ = "Copyright (C) 2020 Andreas Kloeckner"
+"""Minimal example of a grudge driver."""
+
+__copyright__ = """
+Copyright (C) 2020 Andreas Kloeckner
+Copyright (C) 2021 University of Illinois Board of Trustees
+"""
 
 __license__ = """
 Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -24,18 +29,18 @@ THE SOFTWARE.
 import numpy as np
 import numpy.linalg as la  # noqa
 import pyopencl as cl
+import pyopencl.tools as cl_tools
 
-from pytools.obj_array import flat_obj_array
+from arraycontext import PyOpenCLArrayContext, thaw
 
-from meshmode.array_context import PyOpenCLArrayContext
-from meshmode.dof_array import thaw
+from pytools.obj_array import flat_obj_array
 
 from meshmode.mesh import BTAG_ALL, BTAG_NONE  # noqa
 
 from grudge.discretization import DiscretizationCollection
-import grudge.op as op
 from grudge.shortcuts import make_visualizer
-from grudge.symbolic.primitives import TracePair
+
+import grudge.op as op
 
 
 # {{{ wave equation bits
@@ -44,7 +49,7 @@ def wave_flux(dcoll, c, w_tpair):
     u = w_tpair[0]
     v = w_tpair[1:]
 
-    normal = thaw(u.int.array_context, op.normal(dcoll, w_tpair.dd))
+    normal = thaw(op.normal(dcoll, w_tpair.dd), u.int.array_context)
 
     flux_weak = flat_obj_array(
             np.dot(v.avg, normal),
@@ -70,18 +75,28 @@ def wave_operator(dcoll, c, w):
     dir_bc = flat_obj_array(-dir_u, dir_v)
 
     return (
-            op.inverse_mass(dcoll,
-                flat_obj_array(
-                    -c*op.weak_local_div(dcoll, v),
-                    -c*op.weak_local_grad(dcoll, u)
-                    )
-                +  # noqa: W504
-                op.face_mass(dcoll,
-                    wave_flux(dcoll, c=c, w_tpair=op.interior_trace_pair(dcoll, w))
-                    + wave_flux(dcoll, c=c, w_tpair=TracePair(
-                        BTAG_ALL, interior=dir_bval, exterior=dir_bc))
-                    ))
+        op.inverse_mass(
+            dcoll,
+            flat_obj_array(
+                -c*op.weak_local_div(dcoll, v),
+                -c*op.weak_local_grad(dcoll, u)
+            )
+            + op.face_mass(
+                dcoll,
+                sum(
+                    wave_flux(dcoll, c=c, w_tpair=tpair)
+                    for tpair in op.interior_trace_pairs(dcoll, w)
+                )
+                + wave_flux(
+                    dcoll, c=c,
+                    w_tpair=op.bdry_trace_pair(dcoll,
+                                               BTAG_ALL,
+                                               interior=dir_bval,
+                                               exterior=dir_bc)
                 )
+            )
+        )
+    )
 
 # }}}
 
@@ -99,7 +114,7 @@ def bump(actx, dcoll, t=0):
     source_width = 0.05
     source_omega = 3
 
-    nodes = thaw(actx, op.nodes(dcoll))
+    nodes = thaw(op.nodes(dcoll), actx)
     center_dist = flat_obj_array([
         nodes[i] - source_center[i]
         for i in range(dcoll.dim)
@@ -112,10 +127,13 @@ def bump(actx, dcoll, t=0):
             / source_width**2))
 
 
-def main():
+def main(write_output=False):
     cl_ctx = cl.create_some_context()
     queue = cl.CommandQueue(cl_ctx)
-    actx = PyOpenCLArrayContext(queue)
+    actx = PyOpenCLArrayContext(
+        queue,
+        allocator=cl_tools.MemoryPool(cl_tools.ImmediateAllocator(queue))
+    )
 
     dim = 2
     nel_1d = 16
@@ -157,17 +175,25 @@ def main():
         fields = rk4_step(fields, t, dt, rhs)
 
         if istep % 10 == 0:
-            print(f"step: {istep} t: {t} L2: {op.norm(dcoll, fields[0], 2)} "
-                  f"sol max: {op.nodal_max(dcoll, 'vol', fields[0])}")
-            vis.write_vtk_file("fld-wave-eager-%04d.vtu" % istep,
+            print(f"step: {istep} t: {t} "
+                  f"L2: {op.norm(dcoll, fields[0], 2)} "
+                  f"Linf: {op.norm(dcoll, fields[0], np.inf)} "
+                  f"sol max: {op.nodal_max(dcoll, 'vol', fields[0])} "
+                  f"sol min: {op.nodal_min(dcoll, 'vol', fields[0])}")
+            if write_output:
+                vis.write_vtk_file(
+                    f"fld-wave-eager-{istep:04d}.vtu",
                     [
                         ("u", fields[0]),
                         ("v", fields[1:]),
-                        ])
+                    ]
+                )
 
         t += dt
         istep += 1
 
+        # NOTE: These are here to ensure the solution is bounded for the
+        # time interval specified
         assert op.norm(dcoll, fields[0], 2) < 1
 
 
diff --git a/grudge/discretization.py b/grudge/discretization.py
index 2b86c994ec8fa58728f1bb0ef4df255f62549e20..47b425e632287ea702be0026b3646be484ff56da 100644
--- a/grudge/discretization.py
+++ b/grudge/discretization.py
@@ -1,4 +1,13 @@
-__copyright__ = "Copyright (C) 2015-2017 Andreas Kloeckner, Bogdan Enache"
+"""
+.. currentmodule:: grudge
+
+.. autoclass:: DiscretizationCollection
+"""
+
+__copyright__ = """
+Copyright (C) 2015-2017 Andreas Kloeckner, Bogdan Enache
+Copyright (C) 2021 University of Illinois Board of Trustees
+"""
 
 __license__ = """
 Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -21,45 +30,54 @@ THE SOFTWARE.
 """
 
 from pytools import memoize_method
+
 from grudge.dof_desc import (
     DISCR_TAG_BASE, DISCR_TAG_MODAL,
     DTAG_BOUNDARY, DOFDesc, as_dofdesc
 )
+
 import numpy as np  # noqa: F401
-from meshmode.array_context import ArrayContext
-from meshmode.discretization.connection import \
-    FACE_RESTR_INTERIOR, FACE_RESTR_ALL, make_face_restriction
-from meshmode.mesh import BTAG_PARTITION
 
-from warnings import warn
+from arraycontext import ArrayContext
 
+from meshmode.discretization.connection import (
+    FACE_RESTR_INTERIOR,
+    FACE_RESTR_ALL,
+    make_face_restriction
+)
+from meshmode.mesh import Mesh, BTAG_PARTITION
 
-__doc__ = """
-.. autoclass:: DiscretizationCollection
-"""
+from warnings import warn
 
 
 class DiscretizationCollection:
-    """
-    .. automethod :: __init__
+    """A collection of discretizations, defined on the same underlying
+    :class:`~meshmode.mesh.Mesh`, corresponding to various mesh entities
+    (volume, interior facets, boundaries) and associated element
+    groups.
 
-    .. automethod :: discr_from_dd
-    .. automethod :: connection_from_dds
+    .. automethod:: __init__
 
-    .. autoattribute :: dim
-    .. autoattribute :: ambient_dim
-    .. autoattribute :: mesh
+    .. autoattribute:: dim
+    .. autoattribute:: ambient_dim
+    .. autoattribute:: mesh
+    .. autoattribute:: real_dtype
+    .. autoattribute:: complex_dtype
 
-    .. automethod :: empty
-    .. automethod :: zeros
+    .. automethod:: discr_from_dd
+    .. automethod:: connection_from_dds
+
+    .. automethod:: empty
+    .. automethod:: zeros
     """
 
-    def __init__(self, array_context, mesh, order=None,
-            discr_tag_to_group_factory=None, mpi_communicator=None,
-            # FIXME: `quad_tag_to_group_factory` is deprecated
-            quad_tag_to_group_factory=None):
+    def __init__(self, array_context: ArrayContext, mesh: Mesh,
+                 order=None,
+                 discr_tag_to_group_factory=None, mpi_communicator=None,
+                 # FIXME: `quad_tag_to_group_factory` is deprecated
+                 quad_tag_to_group_factory=None):
         """
-        :param discr_tag_to_group_factory: A mapping from discretization tags
+        :arg discr_tag_to_group_factory: A mapping from discretization tags
             (typically one of: :class:`grudge.dof_desc.DISCR_TAG_BASE`,
             :class:`grudge.dof_desc.DISCR_TAG_MODAL`, or
             :class:`grudge.dof_desc.DISCR_TAG_QUAD`) to a
@@ -126,6 +144,7 @@ class DiscretizationCollection:
             self.group_factory_for_discretization_tag(DISCR_TAG_BASE)
         )
 
+        # NOTE: Can be removed when symbolics are completely removed
         # {{{ management of discretization-scoped common subexpressions
 
         from pytools import UniqueNameGenerator
@@ -197,6 +216,26 @@ class DiscretizationCollection:
         return boundary_connections
 
     def get_distributed_boundary_swap_connection(self, dd):
+        warn("`DiscretizationCollection.get_distributed_boundary_swap_connection` "
+             "is deprecated and will go away in 2022. Use "
+             "`DiscretizationCollection.distributed_boundary_swap_connection` "
+             "instead.",
+             DeprecationWarning, stacklevel=2)
+        return self.distributed_boundary_swap_connection(dd)
+
+    def distributed_boundary_swap_connection(self, dd):
+        """Provides a mapping from the base volume discretization
+        to the exterior boundary restriction on a parallel boundary
+        partition described by *dd*. This connection is used to
+        communicate across element boundaries in different parallel
+        partitions during distributed runs.
+
+        :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value
+            convertible to one. The domain tag must be a subclass
+            of :class:`grudge.dof_desc.DTAG_BOUNDARY` with an
+            associated :class:`meshmode.mesh.BTAG_PARTITION`
+            corresponding to a particular communication rank.
+        """
         if dd.discretization_tag is not DISCR_TAG_BASE:
             # FIXME
             raise NotImplementedError(
@@ -211,6 +250,12 @@ class DiscretizationCollection:
 
     @memoize_method
     def discr_from_dd(self, dd):
+        """Provides a :class:`meshmode.discretization.Discretization`
+        object from *dd*.
+
+        :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value
+            convertible to one.
+        """
         dd = as_dofdesc(dd)
 
         discr_tag = dd.discretization_tag
@@ -246,6 +291,16 @@ class DiscretizationCollection:
 
     @memoize_method
     def connection_from_dds(self, from_dd, to_dd):
+        """Provides a mapping (connection) from one discretization to
+        another, e.g. from the volume to the boundary, or from the
+        base to the an overintegrated quadrature discretization, or from
+        a nodal representation to a modal representation.
+
+        :arg from_dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value
+            convertible to one.
+        :arg to_dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value
+            convertible to one.
+        """
         from_dd = as_dofdesc(from_dd)
         to_dd = as_dofdesc(to_dd)
 
@@ -453,6 +508,10 @@ class DiscretizationCollection:
 
     @memoize_method
     def opposite_face_connection(self):
+        """Provides a mapping from the base volume discretization
+        to the exterior boundary restriction on a neighboring element.
+        This does not take into account parallel partitions.
+        """
         from meshmode.discretization.connection import \
                 make_opposite_face_connection
 
@@ -482,28 +541,51 @@ class DiscretizationCollection:
 
     @property
     def dim(self):
+        """Return the topological dimension."""
         return self._volume_discr.dim
 
     @property
     def ambient_dim(self):
+        """Return the dimension of the ambient space."""
         return self._volume_discr.ambient_dim
 
     @property
     def real_dtype(self):
+        """Return the data type used for real-valued arithmetic."""
         return self._volume_discr.real_dtype
 
     @property
     def complex_dtype(self):
+        """Return the data type used for complex-valued arithmetic."""
         return self._volume_discr.complex_dtype
 
     @property
     def mesh(self):
+        """Return the :class:`meshmode.mesh.Mesh` over which the discretization
+        collection is built.
+        """
         return self._volume_discr.mesh
 
     def empty(self, array_context: ArrayContext, dtype=None):
+        """Return an empty :class:`~meshmode.dof_array.DOFArray` defined at
+        the volume nodes: :class:`grudge.dof_desc.DD_VOLUME`.
+
+        :arg array_context: an :class:`~arraycontext.context.ArrayContext`.
+        :arg dtype: type special value 'c' will result in a
+            vector of dtype :attr:`complex_dtype`. If
+            *None* (the default), a real vector will be returned.
+        """
         return self._volume_discr.empty(array_context, dtype)
 
     def zeros(self, array_context: ArrayContext, dtype=None):
+        """Return a zero-initialized :class:`~meshmode.dof_array.DOFArray`
+        defined at the volume nodes, :class:`grudge.dof_desc.DD_VOLUME`.
+
+        :arg array_context: an :class:`~arraycontext.context.ArrayContext`.
+        :arg dtype: type special value 'c' will result in a
+            vector of dtype :attr:`complex_dtype`. If
+            *None* (the default), a real vector will be returned.
+        """
         return self._volume_discr.zeros(array_context, dtype)
 
     def is_volume_where(self, where):
diff --git a/grudge/execution.py b/grudge/execution.py
index 4f62a1d378af65c94f7dfb1dc9fde7199680fe0d..73b5e894839a58c9275781410353f034182fc132 100644
--- a/grudge/execution.py
+++ b/grudge/execution.py
@@ -20,6 +20,9 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 THE SOFTWARE.
 """
 
+
+from arraycontext import ArrayContext, make_loopy_program, thaw
+
 from typing import Optional, Union, Dict
 from numbers import Number
 import numpy as np
@@ -29,8 +32,7 @@ from pytools import memoize_in
 import loopy as lp
 import pyopencl.array  # noqa
 
-from meshmode.dof_array import DOFArray, thaw, flatten, unflatten
-from meshmode.array_context import ArrayContext, make_loopy_program
+from meshmode.dof_array import DOFArray, flatten, unflatten
 
 import grudge.symbolic.mappers as mappers
 from grudge import sym
@@ -72,15 +74,18 @@ class ExecutionMapper(mappers.Evaluator,
 
     def map_node_coordinate_component(self, expr):
         discr = self.dcoll.discr_from_dd(expr.dd)
-        return thaw(self.array_context, discr.nodes(
-            # only save volume nodes or boundary nodes
-            # (but not nodes for interior face discretizations, which are likely only
-            # used once to compute the normals)
-            cached=(
-                discr.ambient_dim == discr.dim
-                or expr.dd.is_boundary_or_partition_interface()
+        return thaw(
+            discr.nodes(
+                # only save volume nodes or boundary nodes
+                # (but not nodes for interior face discretizations, which
+                # are likely only used once to compute the normals)
+                cached=(
+                    discr.ambient_dim == discr.dim
+                    or expr.dd.is_boundary_or_partition_interface()
                 )
-            )[expr.axis])
+            )[expr.axis],
+            self.array_context
+        )
 
     def map_grudge_variable(self, expr):
         from numbers import Number
@@ -313,7 +318,7 @@ class ExecutionMapper(mappers.Evaluator,
 
     def map_opposite_partition_face_swap(self, op, field_expr):
         assert op.dd_in == op.dd_out
-        bdry_conn = self.dcoll.get_distributed_boundary_swap_connection(op.dd_in)
+        bdry_conn = self.dcoll.distributed_boundary_swap_connection(op.dd_in)
         remote_bdry_vec = self.rec(field_expr)  # swapped by RankDataSwapAssign
         return bdry_conn(remote_bdry_vec)
 
diff --git a/grudge/geometry/__init__.py b/grudge/geometry/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..fec1e5958425562a0070b988a11e10dd2b9e1f36
--- /dev/null
+++ b/grudge/geometry/__init__.py
@@ -0,0 +1,62 @@
+__copyright__ = """
+Copyright (C) 2021 University of Illinois Board of Trustees
+"""
+
+__license__ = """
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+"""
+
+
+from grudge.geometry.metrics import (
+    forward_metric_nth_derivative,
+    forward_metric_derivative_mat,
+    inverse_metric_derivative_mat,
+
+    first_fundamental_form,
+    inverse_first_fundamental_form,
+
+    inverse_surface_metric_derivative,
+    pseudoscalar,
+    area_element,
+
+    normal,
+
+    second_fundamental_form,
+    shape_operator,
+    summed_curvature
+)
+
+__all__ = (
+    "forward_metric_nth_derivative",
+    "forward_metric_derivative_mat",
+    "inverse_metric_derivative_mat",
+
+    "first_fundamental_form",
+    "inverse_first_fundamental_form",
+
+    "inverse_surface_metric_derivative",
+    "pseudoscalar",
+    "area_element",
+
+    "normal",
+
+    "second_fundamental_form",
+    "shape_operator",
+    "summed_curvature",
+)
diff --git a/grudge/geometry/metrics.py b/grudge/geometry/metrics.py
new file mode 100644
index 0000000000000000000000000000000000000000..fe0030dd31e90e7702292e9d4c83901b3db37516
--- /dev/null
+++ b/grudge/geometry/metrics.py
@@ -0,0 +1,713 @@
+"""
+.. currentmodule:: grudge.geometry
+
+Coordinate transformations
+--------------------------
+
+.. autofunction:: forward_metric_nth_derivative
+.. autofunction:: forward_metric_derivative_mat
+.. autofunction:: inverse_metric_derivative_mat
+
+.. autofunction:: first_fundamental_form
+.. autofunction:: inverse_first_fundamental_form
+
+Geometry terms
+--------------
+
+.. autofunction:: inverse_surface_metric_derivative
+.. autofunction:: pseudoscalar
+.. autofunction:: area_element
+
+Normal vectors
+--------------
+
+.. autofunction:: normal
+
+Curvature tensors
+-----------------
+
+.. autofunction:: second_fundamental_form
+.. autofunction:: shape_operator
+.. autofunction:: summed_curvature
+"""
+
+__copyright__ = """
+Copyright (C) 2021 University of Illinois Board of Trustees
+"""
+
+__license__ = """
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+"""
+
+
+import numpy as np
+
+from grudge import DiscretizationCollection
+from arraycontext import thaw, freeze, ArrayContext
+from meshmode.dof_array import DOFArray
+
+from grudge.dof_desc import (
+    DD_VOLUME, DOFDesc, DISCR_TAG_BASE
+)
+
+from pymbolic.geometric_algebra import MultiVector
+
+from pytools.obj_array import make_obj_array
+from pytools import memoize_in
+
+
+# {{{ Metric computations
+
+def forward_metric_nth_derivative(
+        actx: ArrayContext, dcoll: DiscretizationCollection,
+        xyz_axis, ref_axes, dd=None) -> DOFArray:
+    r"""Pointwise metric derivatives representing repeated derivatives of the
+    physical coordinate enumerated by *xyz_axis*: :math:`x_{\mathrm{xyz\_axis}}`
+    with respect to the coordiantes on the reference element :math:`\xi_i`:
+
+    .. math::
+
+        D^\alpha x_{\mathrm{xyz\_axis}} =
+        \frac{\partial^{|\alpha|} x_{\mathrm{xyz\_axis}} }{
+            \partial \xi_1^{\alpha_1}\cdots \partial \xi_m^{\alpha_m}}
+
+    where :math:`\alpha` is a multi-index described by *ref_axes*.
+
+    :arg xyz_axis: an integer denoting which physical coordinate to
+        differentiate.
+    :arg ref_axes: a :class:`tuple` of tuples indicating indices of
+        coordinate axes of the reference element to the number of derivatives
+        which will be taken. For example, the value ``((0, 2), (1, 1))``
+        indicates taking the second derivative with respect to the first
+        axis and the first derivative with respect to the second
+        axis. Each axis must occur only once and the tuple must be sorted
+        by the axis index.
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+        Defaults to the base volume discretization.
+    :returns: a :class:`~meshmode.dof_array.DOFArray` containing the pointwise
+        metric derivative at each nodal coordinate.
+    """
+    if dd is None:
+        dd = DD_VOLUME
+
+    inner_dd = dd.with_discr_tag(DISCR_TAG_BASE)
+
+    if isinstance(ref_axes, int):
+        ref_axes = ((ref_axes, 1),)
+
+    if not isinstance(ref_axes, tuple):
+        raise ValueError("ref_axes must be a tuple")
+
+    if tuple(sorted(ref_axes)) != ref_axes:
+        raise ValueError("ref_axes must be sorted")
+
+    if len(set(ref_axes)) != len(ref_axes):
+        raise ValueError("ref_axes must not contain an axis more than once")
+
+    from pytools import flatten
+    flat_ref_axes = flatten([rst_axis] * n for rst_axis, n in ref_axes)
+
+    from meshmode.discretization import num_reference_derivative
+
+    vec = num_reference_derivative(
+        dcoll.discr_from_dd(inner_dd),
+        flat_ref_axes,
+        thaw(dcoll.discr_from_dd(inner_dd).nodes(), actx)[xyz_axis]
+    )
+
+    if dd.uses_quadrature():
+        vec = dcoll.connection_from_dds(inner_dd, dd)(vec)
+
+    return vec
+
+
+def forward_metric_derivative_vector(
+        actx: ArrayContext, dcoll: DiscretizationCollection, rst_axis, dd=None
+        ) -> np.ndarray:
+    r"""Computes an object array containing the forward metric derivatives
+    of each physical coordinate.
+
+    :arg rst_axis: a :class:`tuple` of tuples indicating indices of
+        coordinate axes of the reference element to the number of derivatives
+        which will be taken.
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+        Defaults to the base volume discretization.
+    :returns: an object array of :class:`~meshmode.dof_array.DOFArray`\ s
+        containing the pointwise metric derivatives at each nodal coordinate.
+    """
+    return make_obj_array([
+        forward_metric_nth_derivative(actx, dcoll, i, rst_axis, dd=dd)
+        for i in range(dcoll.ambient_dim)
+        ]
+    )
+
+
+def forward_metric_derivative_mv(
+        actx: ArrayContext, dcoll: DiscretizationCollection, rst_axis, dd=None
+        ) -> MultiVector:
+    r"""Computes a :class:`pymbolic.geometric_algebra.MultiVector` containing
+    the forward metric derivatives of each physical coordinate.
+
+    :arg rst_axis: a :class:`tuple` of tuples indicating indices of
+        coordinate axes of the reference element to the number of derivatives
+        which will be taken.
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+        Defaults to the base volume discretization.
+    :returns: a :class:`pymbolic.geometric_algebra.MultiVector` containing
+        the forward metric derivatives in each physical coordinate.
+    """
+    return MultiVector(
+        forward_metric_derivative_vector(actx, dcoll, rst_axis, dd=dd)
+    )
+
+
+def forward_metric_derivative_mat(
+        actx: ArrayContext, dcoll: DiscretizationCollection, dd=None
+        ) -> np.ndarray:
+    r"""Computes the forward metric derivative matrix, also commonly
+    called the Jacobian matrix, with entries defined as the
+    forward metric derivatives:
+
+    .. math::
+
+        J = \left\lbrack
+            \frac{\partial x_i}{\partial \xi_j}
+            \right\rbrack_{(0, 0) \leq (i, j) \leq (n, m)}
+
+    where :math:`x_1, \dots, x_n` denote the physical coordinates and
+    :math:`\xi_1, \dots, \xi_m` denote coordinates on the reference element.
+    Note that, in the case of immersed manifolds, `J` is not necessarily
+    a square matrix.
+
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+        Defaults to the base volume discretization.
+    :returns: a matrix containing the evaluated forward metric derivatives
+        of each physical coordinate, with respect to each reference coordinate.
+    """
+    ambient_dim = dcoll.ambient_dim
+
+    if dd is None:
+        dd = DD_VOLUME
+
+    dim = dcoll.discr_from_dd(dd).dim
+
+    result = np.zeros((ambient_dim, dim), dtype=object)
+    for j in range(dim):
+        result[:, j] = forward_metric_derivative_vector(actx, dcoll, j, dd=dd)
+
+    return result
+
+
+def first_fundamental_form(actx: ArrayContext, dcoll: DiscretizationCollection,
+        dd=None) -> np.ndarray:
+    r"""Computes the first fundamental form using the Jacobian matrix:
+
+    .. math::
+
+        \begin{bmatrix}
+            E & F \\ F & G
+        \end{bmatrix} :=
+        \begin{bmatrix}
+            (\partial_u x)^2 & \partial_u x \partial_v x \\
+            \partial_u x \partial_v x & (\partial_v x)^2
+        \end{bmatrix} =
+        J^T \cdot J
+
+    where :math:`u, v` are coordinates on the parameterized surface and
+    :math:`x(u, v)` defines a parameterized region. Here, :math:`J` is the
+    corresponding Jacobian matrix.
+
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+        Defaults to the base volume discretization.
+    :returns: a matrix containing coefficients of the first fundamental
+        form.
+    """
+    if dd is None:
+        dd = DD_VOLUME
+
+    mder = forward_metric_derivative_mat(actx, dcoll, dd=dd)
+
+    return mder.T.dot(mder)
+
+
+def inverse_metric_derivative_mat(
+        actx: ArrayContext, dcoll: DiscretizationCollection, dd=None
+        ) -> np.ndarray:
+    r"""Computes the inverse metric derivative matrix, which is
+    the inverse of the Jacobian (forward metric derivative) matrix.
+
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+        Defaults to the base volume discretization.
+    :returns: a matrix containing the evaluated inverse metric derivatives.
+    """
+    ambient_dim = dcoll.ambient_dim
+
+    if dd is None:
+        dd = DD_VOLUME
+
+    dim = dcoll.discr_from_dd(dd).dim
+
+    result = np.zeros((ambient_dim, dim), dtype=object)
+    for i in range(dim):
+        for j in range(ambient_dim):
+            result[i, j] = inverse_metric_derivative(
+                actx, dcoll, i, j, dd=dd
+            )
+
+    return result
+
+
+def inverse_first_fundamental_form(
+        actx: ArrayContext, dcoll: DiscretizationCollection, dd=None
+        ) -> np.ndarray:
+    r"""Computes the inverse of the first fundamental form:
+
+    .. math::
+
+        \begin{bmatrix}
+            E & F \\ F & G
+        \end{bmatrix}^{-1} =
+        \frac{1}{E G - F^2}
+        \begin{bmatrix}
+            G & -F \\ -F & E
+        \end{bmatrix}
+
+    where :math:`E, F, G` are coefficients of the first fundamental form.
+
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+        Defaults to the base volume discretization.
+    :returns: a matrix containing coefficients of the inverse of the
+        first fundamental form.
+    """
+    if dd is None:
+        dd = DD_VOLUME
+
+    dim = dcoll.discr_from_dd(dd).dim
+
+    if dcoll.ambient_dim == dim:
+        inv_mder = inverse_metric_derivative_mat(actx, dcoll, dd=dd)
+        inv_form1 = inv_mder.dot(inv_mder.T)
+    else:
+        form1 = first_fundamental_form(actx, dcoll, dd=dd)
+
+        if dim == 1:
+            inv_form1 = 1.0 / form1
+        elif dim == 2:
+            (E, F), (_, G) = form1      # noqa: N806
+            inv_form1 = 1.0 / (E * G - F * F) * np.stack(
+                [make_obj_array([G, -F]),
+                 make_obj_array([-F, E])]
+            )
+        else:
+            raise ValueError(f"{dim}D surfaces not supported" % dim)
+
+    return inv_form1
+
+
+def inverse_metric_derivative(
+        actx: ArrayContext, dcoll: DiscretizationCollection, rst_axis, xyz_axis, dd
+        ) -> DOFArray:
+    r"""Computes the inverse metric derivative of the physical
+    coordinate enumerated by *xyz_axis* with respect to the
+    reference axis *rst_axis*.
+
+    :arg rst_axis: an integer denoting the reference coordinate axis.
+    :arg xyz_axis: an integer denoting the physical coordinate axis.
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+        Defaults to the base volume discretization.
+    :returns: a :class:`~meshmode.dof_array.DOFArray` containing the
+        inverse metric derivative at each nodal coordinate.
+    """
+
+    dim = dcoll.dim
+    if dim != dcoll.ambient_dim:
+        raise ValueError(
+            "Not clear what inverse_metric_derivative means if "
+            "the derivative matrix is not square!"
+        )
+
+    par_vecs = [forward_metric_derivative_mv(actx, dcoll, rst, dd)
+                for rst in range(dim)]
+
+    # Yay Cramer's rule!
+    from functools import reduce, partial
+    from operator import xor as outerprod_op
+    outerprod = partial(reduce, outerprod_op)
+
+    def outprod_with_unit(i, at):
+        unit_vec = np.zeros(dim)
+        unit_vec[i] = 1
+
+        vecs = par_vecs[:]
+        vecs[at] = MultiVector(unit_vec)
+
+        return outerprod(vecs)
+
+    volume_pseudoscalar_inv = outerprod(
+        forward_metric_derivative_mv(actx, dcoll, rst_axis, dd)
+        for rst_axis in range(dim)
+    ).inv()
+
+    result = (outprod_with_unit(xyz_axis, rst_axis)
+              * volume_pseudoscalar_inv).as_scalar()
+
+    return result
+
+
+def inverse_surface_metric_derivative(
+        actx: ArrayContext, dcoll: DiscretizationCollection,
+        rst_axis, xyz_axis, dd=None):
+    r"""Computes the inverse surface metric derivative of the physical
+    coordinate enumerated by *xyz_axis* with respect to the
+    reference axis *rst_axis*. These geometric terms are used in the
+    transformation of physical gradients.
+
+    :arg rst_axis: an integer denoting the reference coordinate axis.
+    :arg xyz_axis: an integer denoting the physical coordinate axis.
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+        Defaults to the base volume discretization.
+    :returns: a :class:`~meshmode.dof_array.DOFArray` containing the
+        inverse metric derivative at each nodal coordinate.
+    """
+    dim = dcoll.dim
+    ambient_dim = dcoll.ambient_dim
+
+    @memoize_in(dcoll, (inverse_surface_metric_derivative, dd,
+                        "inv_metric_deriv_rst%s_xyz%s_adim%s_gdim%s"
+                        % (rst_axis, xyz_axis, ambient_dim, dim)))
+    def _inv_surf_metric_deriv():
+        if ambient_dim == dim:
+            imd = inverse_metric_derivative(
+                actx, dcoll, rst_axis, xyz_axis, dd=dd
+            )
+        else:
+            inv_form1 = inverse_first_fundamental_form(actx, dcoll, dd=dd)
+            imd = sum(
+                inv_form1[rst_axis, d]*forward_metric_nth_derivative(
+                    actx, dcoll, xyz_axis, d, dd=dd
+                ) for d in range(dim)
+            )
+        return freeze(imd, actx)
+    return thaw(_inv_surf_metric_deriv(), actx)
+
+
+def _signed_face_ones(
+        actx: ArrayContext, dcoll: DiscretizationCollection, dd
+        ) -> DOFArray:
+
+    assert dd.is_trace()
+
+    # NOTE: ignore quadrature_tags on dd, since we only care about
+    # the face_id here
+    all_faces_conn = dcoll.connection_from_dds(
+        DD_VOLUME, DOFDesc(dd.domain_tag)
+    )
+    signed_face_ones = dcoll.discr_from_dd(dd).zeros(
+        actx, dtype=dcoll.real_dtype
+    ) + 1
+    for igrp, grp in enumerate(all_faces_conn.groups):
+        for batch in grp.batches:
+            i = actx.thaw(batch.to_element_indices)
+            grp_field = signed_face_ones[igrp].reshape(-1)
+            grp_field[i] = \
+                (2.0 * (batch.to_element_face % 2) - 1.0) * grp_field[i]
+
+    return signed_face_ones
+
+
+def parametrization_derivative(
+        actx: ArrayContext, dcoll: DiscretizationCollection, dd
+        ) -> MultiVector:
+    r"""Computes the product of forward metric derivatives spanning the
+    tangent space with topological dimension *dim*.
+
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+        Defaults to the base volume discretization.
+    :returns: a :class:`pymbolic.geometric_algebra.MultiVector` containing
+        the product of metric derivatives.
+    """
+    if dd is None:
+        dd = DD_VOLUME
+
+    dim = dcoll.discr_from_dd(dd).dim
+    if dim == 0:
+        from pymbolic.geometric_algebra import get_euclidean_space
+
+        return MultiVector(
+            _signed_face_ones(actx, dcoll, dd),
+            space=get_euclidean_space(dcoll.ambient_dim)
+        )
+
+    from pytools import product
+
+    return product(
+        forward_metric_derivative_mv(actx, dcoll, rst_axis, dd)
+        for rst_axis in range(dim)
+    )
+
+
+def pseudoscalar(actx: ArrayContext, dcoll: DiscretizationCollection,
+        dd=None) -> MultiVector:
+    r"""Computes the field of pseudoscalars for the domain/discretization
+    identified by *dd*.
+
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+        Defaults to the base volume discretization.
+    :returns: A :class:`~pymbolic.geometric_algebra.MultiVector` of
+        :class:`~meshmode.dof_array.DOFArray`\ s.
+    """
+    if dd is None:
+        dd = DD_VOLUME
+
+    return parametrization_derivative(actx, dcoll, dd).project_max_grade()
+
+
+def area_element(
+        actx: ArrayContext, dcoll: DiscretizationCollection, dd=None
+        ) -> DOFArray:
+    r"""Computes the scale factor used to transform integrals from reference
+    to global space.
+
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+        Defaults to the base volume discretization.
+    :returns: a :class:`~meshmode.dof_array.DOFArray` containing the transformed
+        volumes for each element.
+    """
+    if dd is None:
+        dd = DD_VOLUME
+
+    dim = dcoll.discr_from_dd(dd).dim
+
+    @memoize_in(dcoll, (area_element, dd,
+                        "area_elements_adim%s_gdim%s"
+                        % (dcoll.ambient_dim, dim)))
+    def _area_elements():
+        return freeze(actx.np.sqrt(
+            pseudoscalar(actx, dcoll, dd=dd).norm_squared()), actx)
+
+    return thaw(_area_elements(), actx)
+
+# }}}
+
+
+# {{{ Surface normal vectors
+
+def rel_mv_normal(
+        actx: ArrayContext, dcoll: DiscretizationCollection, dd=None) -> MultiVector:
+    r"""Computes surface normals at each nodal location as a
+    :class:`~pymbolic.geometric_algebra.MultiVector` relative to the
+    pseudoscalar of the discretization described by *dd*.
+
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+    """
+    import grudge.dof_desc as dof_desc
+
+    dd = dof_desc.as_dofdesc(dd)
+
+    # NOTE: Don't be tempted to add a sign here. As it is, it produces
+    # exterior normals for positively oriented curves.
+
+    pder = pseudoscalar(actx, dcoll, dd=dd) / area_element(actx, dcoll, dd=dd)
+
+    # Dorst Section 3.7.2
+    return pder << pder.I.inv()
+
+
+def mv_normal(
+        actx: ArrayContext, dcoll: DiscretizationCollection, dd,
+        ) -> MultiVector:
+    """Exterior unit normal as a :class:`~pymbolic.geometric_algebra.MultiVector`.
+    This supports both volume discretizations
+    (where ambient == topological dimension) and surface discretizations
+    (where ambient == topological dimension + 1). In the latter case, extra
+    processing ensures that the returned normal is in the local tangent space
+    of the element at the point where the normal is being evaluated.
+
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc` as the surface discretization.
+    :returns: a :class:`~pymbolic.geometric_algebra.MultiVector`
+        containing the unit normals.
+    """
+    import grudge.dof_desc as dof_desc
+
+    dd = dof_desc.as_dofdesc(dd)
+
+    dim = dcoll.discr_from_dd(dd).dim
+    ambient_dim = dcoll.ambient_dim
+
+    if dim == ambient_dim:
+        raise ValueError("may only request normals on domains whose topological "
+                f"dimension ({dim}) differs from "
+                f"their ambient dimension ({ambient_dim})")
+
+    if dim == ambient_dim - 1:
+        return rel_mv_normal(actx, dcoll, dd=dd)
+
+    # NOTE: In the case of (d - 2)-dimensional curves, we don't really have
+    # enough information on the face to decide what an "exterior face normal"
+    # is (e.g the "normal" to a 1D curve in 3D space is actually a
+    # "normal plane")
+    #
+    # The trick done here is that we take the surface normal, move it to the
+    # face and then take a cross product with the face tangent to get the
+    # correct exterior face normal vector.
+    assert dim == ambient_dim - 2
+
+    from grudge.op import project
+    import grudge.dof_desc as dof_desc
+
+    volm_normal = MultiVector(
+        project(dcoll, dof_desc.DD_VOLUME, dd,
+                rel_mv_normal(
+                    actx, dcoll,
+                    dd=dof_desc.DD_VOLUME
+                ).as_vector(dtype=object))
+    )
+    pder = pseudoscalar(actx, dcoll, dd=dd)
+
+    mv = -(volm_normal ^ pder) << volm_normal.I.inv()
+
+    return mv / actx.np.sqrt(mv.norm_squared())
+
+
+def normal(actx: ArrayContext, dcoll: DiscretizationCollection, dd):
+    """Get the unit normal to the specified surface discretization, *dd*.
+    This supports both volume discretizations
+    (where ambient == topological dimension) and surface discretizations
+    (where ambient == topological dimension + 1). In the latter case, extra
+    processing ensures that the returned normal is in the local tangent space
+    of the element at the point where the normal is being evaluated.
+
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc` as the surface discretization.
+    :returns: an object array of :class:`~meshmode.dof_array.DOFArray`
+        containing the unit normals at each nodal location.
+    """
+    return mv_normal(actx, dcoll, dd).as_vector(dtype=object)
+
+# }}}
+
+
+# {{{ Curvature computations
+
+def second_fundamental_form(
+        actx: ArrayContext, dcoll: DiscretizationCollection, dd=None
+        ) -> np.ndarray:
+    r"""Computes the second fundamental form:
+
+    .. math::
+
+        S(x) = \begin{bmatrix}
+            \partial_{uu} x\cdot n & \partial_{uv} x\cdot n \\
+            \partial_{uv} x\cdot n & \partial_{vv} x\cdot n
+        \end{bmatrix}
+
+    where :math:`n` is the surface normal, :math:`x(u, v)` defines a parameterized
+    surface, and :math:`u,v` are coordinates on the parameterized surface.
+
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+    :returns: a rank-2 object array describing second fundamental form.
+    """
+
+    if dd is None:
+        dd = DD_VOLUME
+
+    dim = dcoll.discr_from_dd(dd).dim
+    normal = rel_mv_normal(actx, dcoll, dd=dd).as_vector(dtype=object)
+
+    if dim == 1:
+        second_ref_axes = [((0, 2),)]
+    elif dim == 2:
+        second_ref_axes = [((0, 2),), ((0, 1), (1, 1)), ((1, 2),)]
+    else:
+        raise ValueError("%dD surfaces not supported" % dim)
+
+    from pytools import flatten
+
+    form2 = np.empty((dim, dim), dtype=object)
+
+    for ref_axes in second_ref_axes:
+        i, j = flatten([rst_axis] * n for rst_axis, n in ref_axes)
+
+        ruv = make_obj_array(
+            [forward_metric_nth_derivative(actx, dcoll, xyz_axis, ref_axes, dd=dd)
+             for xyz_axis in range(dcoll.ambient_dim)]
+        )
+        form2[i, j] = form2[j, i] = normal.dot(ruv)
+
+    return form2
+
+
+def shape_operator(actx: ArrayContext, dcoll: DiscretizationCollection,
+        dd=None) -> np.ndarray:
+    r"""Computes the shape operator (also called the curvature tensor) containing
+    second order derivatives:
+
+    .. math::
+
+        C(x) = \begin{bmatrix}
+            \partial_{uu} x & \partial_{uv} x \\
+            \partial_{uv} x & \partial_{vv} x
+        \end{bmatrix}
+
+    where :math:`x(u, v)` defines a parameterized surface, and :math:`u,v` are
+    coordinates on the parameterized surface.
+
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+    :returns: a rank-2 object array describing the shape operator.
+    """
+
+    inv_form1 = inverse_first_fundamental_form(actx, dcoll, dd=dd)
+    form2 = second_fundamental_form(actx, dcoll, dd=dd)
+
+    return -form2.dot(inv_form1)
+
+
+def summed_curvature(actx: ArrayContext, dcoll: DiscretizationCollection,
+        dd=None) -> DOFArray:
+    r"""Computes the sum of the principal curvatures:
+
+    .. math::
+
+        \kappa = \operatorname{Trace}(C(x))
+
+    where :math:`x(u, v)` defines a parameterized surface, :math:`u,v` are
+    coordinates on the parameterized surface, and :math:`C(x)` is the shape
+    operator.
+
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+    :returns: a :class:`~meshmode.dof_array.DOFArray` containing the summed
+        curvature at each nodal coordinate.
+    """
+
+    if dd is None:
+        dd = DD_VOLUME
+
+    dim = dcoll.ambient_dim - 1
+
+    if dcoll.ambient_dim == 1:
+        return 0.0
+
+    if dcoll.ambient_dim == dim:
+        return 0.0
+
+    return np.trace(shape_operator(actx, dcoll, dd=dd))
+
+# }}}
+
+
+# vim: foldmethod=marker
diff --git a/grudge/models/__init__.py b/grudge/models/__init__.py
index 8486bd2e059f0026081cc88a90bb258a5a08f9d5..bc780365cd89169aae11358288b17d57825c61cd 100644
--- a/grudge/models/__init__.py
+++ b/grudge/models/__init__.py
@@ -31,18 +31,6 @@ class Operator:
     documentation, to group related classes together in an inheritance
     tree.
     """
-    pass
-
-
-class TimeDependentOperator(Operator):
-    """A base class for time-dependent Discontinuous Galerkin operators.
-
-    You may derive your own operators from this class, but, at present
-    this class provides no functionality. Its function is merely as
-    documentation, to group related classes together in an inheritance
-    tree.
-    """
-    pass
 
 
 class HyperbolicOperator(Operator):
diff --git a/grudge/models/advection.py b/grudge/models/advection.py
index 980425e5be60079d46bd2067936cdad83f164ec7..37ed429be37250f102812127c5eab28a7a495b91 100644
--- a/grudge/models/advection.py
+++ b/grudge/models/advection.py
@@ -1,6 +1,9 @@
 """Operators modeling advective phenomena."""
 
-__copyright__ = "Copyright (C) 2009-2017 Andreas Kloeckner, Bogdan Enache"
+__copyright__ = """
+Copyright (C) 2009-2017 Andreas Kloeckner, Bogdan Enache
+Copyright (C) 2021 University of Illinois Board of Trustees
+"""
 
 __license__ = """
 Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -24,30 +27,33 @@ THE SOFTWARE.
 
 
 import numpy as np
-import numpy.linalg as la
+import grudge.op as op
+import types
+
+from arraycontext.container.traversal import thaw
 
 from grudge.models import HyperbolicOperator
-from grudge import sym
 
 
 # {{{ fluxes
 
-def advection_weak_flux(flux_type, u, velocity):
-    normal = sym.normal(u.dd, len(velocity))
-    v_dot_n = sym.cse(velocity.dot(normal), "v_dot_normal")
+def advection_weak_flux(dcoll, flux_type, u_tpair, velocity):
+    r"""Compute the numerical flux for the advection operator
+    $(v \cdot \nabla)u$.
+    """
+    actx = u_tpair.int.array_context
+    dd = u_tpair.dd
+    normal = thaw(op.normal(dcoll, dd), actx)
+    v_dot_n = np.dot(velocity, normal)
 
     flux_type = flux_type.lower()
     if flux_type == "central":
-        return u.avg * v_dot_n
+        return u_tpair.avg * v_dot_n
     elif flux_type == "lf":
-        norm_v = sym.sqrt((velocity**2).sum())
-        return u.avg * v_dot_n + 0.5 * norm_v * (u.int - u.ext)
+        norm_v = np.sqrt(sum(velocity**2))
+        return u_tpair.avg * v_dot_n + 0.5 * norm_v * (u_tpair.int - u_tpair.ext)
     elif flux_type == "upwind":
-        u_upwind = sym.If(
-                sym.Comparison(v_dot_n, ">", 0),
-                u.int,      # outflow
-                u.ext       # inflow
-                )
+        u_upwind = actx.np.where(v_dot_n > 0, u_tpair.int, u_tpair.ext)
         return u_upwind * v_dot_n
     else:
         raise ValueError(f"flux '{flux_type}' is not implemented")
@@ -58,210 +64,312 @@ def advection_weak_flux(flux_type, u, velocity):
 # {{{ constant-coefficient advection
 
 class AdvectionOperatorBase(HyperbolicOperator):
-    flux_types = [
-            "central",
-            "upwind",
-            "lf"
-            ]
-
-    def __init__(self, v, inflow_u, flux_type="central"):
-        self.ambient_dim = len(v)
-        self.v = v
-        self.inflow_u = inflow_u
-        self.flux_type = flux_type
+    flux_types = ["central", "upwind", "lf"]
 
+    def __init__(self, dcoll, v, inflow_u=None, flux_type="central"):
         if flux_type not in self.flux_types:
             raise ValueError(f"unknown flux type: '{flux_type}'")
 
-    def weak_flux(self, u):
-        return advection_weak_flux(self.flux_type, u, self.v)
+        if inflow_u is not None:
+            if not isinstance(inflow_u, types.LambdaType):
+                raise ValueError(
+                    "A specified inflow_u must be a lambda function of time `t`"
+                )
+
+        self.dcoll = dcoll
+        self.v = v
+        self.inflow_u = inflow_u
+        self.flux_type = flux_type
+
+    def weak_flux(self, u_tpair):
+        return advection_weak_flux(self.dcoll, self.flux_type, u_tpair, self.v)
 
     def max_eigenvalue(self, t=None, fields=None, discr=None):
-        return la.norm(self.v)
+        return np.linalg.norm(self.v)
 
 
 class StrongAdvectionOperator(AdvectionOperatorBase):
-    def flux(self, u):
-        normal = sym.normal(u.dd, self.ambient_dim)
-        v_dot_normal = sym.cse(self.v.dot(normal), "v_dot_normal")
+    def flux(self, u_tpair):
+        actx = u_tpair.int.array_context
+        dd = u_tpair.dd
+        normal = thaw(op.normal(self.dcoll, dd), actx)
+        v_dot_normal = np.dot(self.v, normal)
 
-        return u.int * v_dot_normal - self.weak_flux(u)
+        return u_tpair.int * v_dot_normal - self.weak_flux(u_tpair)
 
-    def sym_operator(self):
+    def operator(self, t, u):
         from meshmode.mesh import BTAG_ALL
 
-        u = sym.var("u")
+        dcoll = self.dcoll
 
-        def flux(pair):
-            return sym.project(pair.dd, "all_faces")(
-                    self.flux(pair))
+        def flux(tpair):
+            return op.project(dcoll, tpair.dd, "all_faces", self.flux(tpair))
 
-        return (
-                - self.v.dot(sym.nabla(self.ambient_dim)*u)
-                + sym.InverseMassOperator()(
-                    sym.FaceMassOperator()(
-                        flux(sym.int_tpair(u))
-                        + flux(sym.bv_tpair(BTAG_ALL, u, self.inflow_u))
+        if self.inflow_u is not None:
+            inflow_flux = flux(op.bv_trace_pair(dcoll,
+                                                BTAG_ALL,
+                                                interior=u,
+                                                exterior=self.inflow_u(t)))
+        else:
+            inflow_flux = 0
 
-                        # FIXME: Add back support for inflow/outflow tags
-                        #+ flux(sym.bv_tpair(self.inflow_tag, u, bc_in))
-                        #+ flux(sym.bv_tpair(self.outflow_tag, u, bc_out))
-                        )))
+        return (
+            -self.v.dot(op.local_grad(dcoll, u))
+            + op.inverse_mass(
+                dcoll,
+                op.face_mass(
+                    dcoll,
+                    sum(flux(tpair) for tpair in op.interior_trace_pairs(dcoll, u))
+                    + inflow_flux
+
+                    # FIXME: Add support for inflow/outflow tags
+                    # + flux(op.bv_trace_pair(dcoll,
+                    #                         self.inflow_tag,
+                    #                         interior=u,
+                    #                         exterior=bc_in))
+                    # + flux(op.bv_trace_pair(dcoll,
+                    #                         self.outflow_tag,
+                    #                         interior=u,
+                    #                         exterior=bc_out))
+                )
+            )
+        )
 
 
 class WeakAdvectionOperator(AdvectionOperatorBase):
-    def flux(self, u):
-        return self.weak_flux(u)
+    def flux(self, u_tpair):
+        return self.weak_flux(u_tpair)
 
-    def sym_operator(self):
+    def operator(self, t, u):
         from meshmode.mesh import BTAG_ALL
 
-        u = sym.var("u")
+        dcoll = self.dcoll
 
-        def flux(pair):
-            return sym.project(pair.dd, "all_faces")(
-                    self.flux(pair))
+        def flux(tpair):
+            return op.project(dcoll, tpair.dd, "all_faces", self.flux(tpair))
 
-        bc_in = self.inflow_u
-        # bc_out = sym.project(DD_VOLUME, self.outflow_tag)(u)
+        if self.inflow_u is not None:
+            inflow_flux = flux(op.bv_trace_pair(dcoll,
+                                                BTAG_ALL,
+                                                interior=u,
+                                                exterior=self.inflow_u(t)))
+        else:
+            inflow_flux = 0
 
-        return sym.InverseMassOperator()(
-                np.dot(
-                    self.v, sym.stiffness_t(self.ambient_dim)*u)
-                - sym.FaceMassOperator()(
-                    flux(sym.int_tpair(u))
-                    + flux(sym.bv_tpair(BTAG_ALL, u, bc_in))
-
-                    # FIXME: Add back support for inflow/outflow tags
-                    #+ flux(sym.bv_tpair(self.inflow_tag, u, bc_in))
-                    #+ flux(sym.bv_tpair(self.outflow_tag, u, bc_out))
-                    ))
+        return (
+            op.inverse_mass(
+                dcoll,
+                np.dot(self.v, op.weak_local_grad(dcoll, u))
+                - op.face_mass(
+                    dcoll,
+                    sum(flux(tpair) for tpair in op.interior_trace_pairs(dcoll, u))
+                    + inflow_flux
+
+                    # FIXME: Add support for inflow/outflow tags
+                    # + flux(op.bv_trace_pair(dcoll,
+                    #                         self.inflow_tag,
+                    #                         interior=u,
+                    #                         exterior=bc_in))
+                    # + flux(op.bv_trace_pair(dcoll,
+                    #                         self.outflow_tag,
+                    #                         interior=u,
+                    #                         exterior=bc_out))
+                )
+            )
+        )
 
 # }}}
 
 
+def to_quad_int_tpairs(dcoll, u, quad_tag):
+    from grudge.dof_desc import DISCR_TAG_QUAD
+    from grudge.trace_pair import TracePair
+
+    if issubclass(quad_tag, DISCR_TAG_QUAD):
+        return [
+            TracePair(
+                tpair.dd.with_discr_tag(quad_tag),
+                interior=op.project(
+                    dcoll, tpair.dd,
+                    tpair.dd.with_discr_tag(quad_tag), tpair.int
+                ),
+                exterior=op.project(
+                    dcoll, tpair.dd,
+                    tpair.dd.with_discr_tag(quad_tag), tpair.ext
+                )
+            ) for tpair in op.interior_trace_pairs(dcoll, u)
+        ]
+    else:
+        return op.interior_trace_pairs(dcoll, u)
+
+
 # {{{ variable-coefficient advection
 
 class VariableCoefficientAdvectionOperator(AdvectionOperatorBase):
-    def __init__(self, v, inflow_u, flux_type="central", quad_tag="product"):
-        super().__init__(
-                v, inflow_u, flux_type=flux_type)
+    def __init__(self, dcoll, v, inflow_u, flux_type="central", quad_tag=None):
+        super().__init__(dcoll, v, inflow_u, flux_type=flux_type)
+
+        if quad_tag is None:
+            from grudge.dof_desc import DISCR_TAG_BASE
+            quad_tag = DISCR_TAG_BASE
 
         self.quad_tag = quad_tag
 
-    def flux(self, u):
+    def flux(self, u_tpair):
         from grudge.dof_desc import DD_VOLUME
 
-        surf_v = sym.project(DD_VOLUME, u.dd)(self.v)
-        return advection_weak_flux(self.flux_type, u, surf_v)
+        surf_v = op.project(self.dcoll, DD_VOLUME, u_tpair.dd, self.v)
+        return advection_weak_flux(self.dcoll, self.flux_type, u_tpair, surf_v)
 
-    def sym_operator(self):
+    def operator(self, t, u):
         from grudge.dof_desc import DOFDesc, DD_VOLUME, DTAG_VOLUME_ALL
         from meshmode.mesh import BTAG_ALL
         from meshmode.discretization.connection import FACE_RESTR_ALL
 
-        u = sym.var("u")
-
-        def flux(pair):
-            return sym.project(pair.dd, face_dd)(self.flux(pair))
-
         face_dd = DOFDesc(FACE_RESTR_ALL, self.quad_tag)
         boundary_dd = DOFDesc(BTAG_ALL, self.quad_tag)
         quad_dd = DOFDesc(DTAG_VOLUME_ALL, self.quad_tag)
 
-        to_quad = sym.project(DD_VOLUME, quad_dd)
-        stiff_t_op = sym.stiffness_t(self.ambient_dim,
-                dd_in=quad_dd, dd_out=DD_VOLUME)
+        dcoll = self.dcoll
+
+        def flux(tpair):
+            return op.project(dcoll, tpair.dd, face_dd, self.flux(tpair))
+
+        def to_quad(arg):
+            return op.project(dcoll, DD_VOLUME, quad_dd, arg)
+
+        if self.inflow_u is not None:
+            inflow_flux = flux(op.bv_trace_pair(dcoll,
+                                                boundary_dd,
+                                                interior=u,
+                                                exterior=self.inflow_u(t)))
+        else:
+            inflow_flux = 0
 
         quad_v = to_quad(self.v)
         quad_u = to_quad(u)
 
-        return sym.InverseMassOperator()(
-                sum(stiff_t_op[n](quad_u * quad_v[n])
-                    for n in range(self.ambient_dim))
-                - sym.FaceMassOperator(face_dd, DD_VOLUME)(
-                    flux(sym.int_tpair(u, self.quad_tag))
-                    + flux(sym.bv_tpair(boundary_dd, u, self.inflow_u))
-
-                    # FIXME: Add back support for inflow/outflow tags
-                    #+ flux(sym.bv_tpair(self.inflow_tag, u, bc_in))
-                    #+ flux(sym.bv_tpair(self.outflow_tag, u, bc_out))
-                ))
+        return (
+            op.inverse_mass(
+                dcoll,
+                sum(op.weak_local_d_dx(dcoll, quad_dd, d, quad_u * quad_v[d])
+                    for d in range(dcoll.ambient_dim))
+                - op.face_mass(
+                    dcoll,
+                    face_dd,
+                    sum(flux(quad_tpair)
+                        for quad_tpair in to_quad_int_tpairs(dcoll, u,
+                                                             self.quad_tag))
+                    + inflow_flux
+
+                    # FIXME: Add support for inflow/outflow tags
+                    # + flux(op.bv_trace_pair(dcoll,
+                    #                         self.inflow_tag,
+                    #                         interior=u,
+                    #                         exterior=bc_in))
+                    # + flux(op.bv_trace_pair(dcoll,
+                    #                         self.outflow_tag,
+                    #                         interior=u,
+                    #                         exterior=bc_out))
+                )
+            )
+        )
+
 # }}}
 
 
 # {{{ closed surface advection
 
-def v_dot_n_tpair(velocity, dd=None):
-    from grudge.dof_desc import DOFDesc
+def v_dot_n_tpair(actx, dcoll, velocity, trace_dd):
+    from grudge.dof_desc import DTAG_BOUNDARY
+    from grudge.trace_pair import TracePair
     from meshmode.discretization.connection import FACE_RESTR_INTERIOR
 
-    if dd is None:
-        dd = DOFDesc(FACE_RESTR_INTERIOR)
+    normal = thaw(op.normal(dcoll, trace_dd.with_discr_tag(None)), actx)
+    v_dot_n = velocity.dot(normal)
+    i = op.project(dcoll, trace_dd.with_discr_tag(None), trace_dd, v_dot_n)
 
-    ambient_dim = len(velocity)
-    normal = sym.normal(dd.with_discr_tag(None),
-                        ambient_dim, dim=ambient_dim - 2)
+    if trace_dd.domain_tag is FACE_RESTR_INTERIOR:
+        e = dcoll.opposite_face_connection()(i)
+    elif isinstance(trace_dd.domain_tag, DTAG_BOUNDARY):
+        e = dcoll.distributed_boundary_swap_connection(trace_dd)(i)
+    else:
+        raise ValueError("Unrecognized domain tag: %s" % trace_dd.domain_tag)
 
-    return sym.int_tpair(velocity.dot(normal),
-            qtag=dd.discretization_tag,
-            from_dd=dd.with_discr_tag(None))
+    return TracePair(trace_dd, interior=i, exterior=e)
 
 
-def surface_advection_weak_flux(flux_type, u, velocity):
-    v_dot_n = v_dot_n_tpair(velocity, dd=u.dd)
+def surface_advection_weak_flux(dcoll, flux_type, u_tpair, velocity):
+    actx = u_tpair.int.array_context
+    v_dot_n = v_dot_n_tpair(actx, dcoll, velocity, trace_dd=u_tpair.dd)
     # NOTE: the normals in v_dot_n point to the exterior of their respective
     # elements, so this is actually just an average
-    v_dot_n = sym.cse(0.5 * (v_dot_n.int - v_dot_n.ext), "v_dot_normal")
+    v_dot_n = 0.5 * (v_dot_n.int - v_dot_n.ext)
 
     flux_type = flux_type.lower()
     if flux_type == "central":
-        return u.avg * v_dot_n
+        return u_tpair.avg * v_dot_n
     elif flux_type == "lf":
-        return u.avg * v_dot_n + 0.5 * sym.fabs(v_dot_n) * (u.int - u.ext)
+        return (u_tpair.avg * v_dot_n
+                + 0.5 * actx.np.fabs(v_dot_n) * (u_tpair.int - u_tpair.ext))
     else:
         raise ValueError(f"flux '{flux_type}' is not implemented")
 
 
 class SurfaceAdvectionOperator(AdvectionOperatorBase):
-    def __init__(self, v, flux_type="central", quad_tag=None):
-        super().__init__(
-                v, inflow_u=None, flux_type=flux_type)
+    def __init__(self, dcoll, v, flux_type="central", quad_tag=None):
+        super().__init__(dcoll, v, inflow_u=None, flux_type=flux_type)
+
+        if quad_tag is None:
+            from grudge.dof_desc import DISCR_TAG_BASE
+            quad_tag = DISCR_TAG_BASE
+
         self.quad_tag = quad_tag
 
-    def flux(self, u):
+    def flux(self, u_tpair):
         from grudge.dof_desc import DD_VOLUME
 
-        surf_v = sym.project(DD_VOLUME, u.dd.with_discr_tag(None))(self.v)
-        return surface_advection_weak_flux(self.flux_type, u, surf_v)
+        surf_v = op.project(self.dcoll, DD_VOLUME,
+                            u_tpair.dd.with_discr_tag(None), self.v)
+        return surface_advection_weak_flux(self.dcoll,
+                                           self.flux_type,
+                                           u_tpair,
+                                           surf_v)
 
-    def sym_operator(self):
+    def operator(self, t, u):
         from grudge.dof_desc import DOFDesc, DD_VOLUME, DTAG_VOLUME_ALL
         from meshmode.discretization.connection import FACE_RESTR_ALL
 
-        u = sym.var("u")
-
-        def flux(pair):
-            return sym.project(pair.dd, face_dd)(self.flux(pair))
-
         face_dd = DOFDesc(FACE_RESTR_ALL, self.quad_tag)
         quad_dd = DOFDesc(DTAG_VOLUME_ALL, self.quad_tag)
 
-        to_quad = sym.project(DD_VOLUME, quad_dd)
-        stiff_t_op = sym.stiffness_t(self.ambient_dim,
-                dd_in=quad_dd, dd_out=DD_VOLUME)
+        dcoll = self.dcoll
+
+        def flux(tpair):
+            return op.project(dcoll, tpair.dd, face_dd, self.flux(tpair))
+
+        def to_quad(arg):
+            return op.project(dcoll, DD_VOLUME, quad_dd, arg)
 
         quad_v = to_quad(self.v)
         quad_u = to_quad(u)
 
-        return sym.InverseMassOperator()(
-                sum(stiff_t_op[n](quad_u * quad_v[n])
-                    for n in range(self.ambient_dim))
-                - sym.FaceMassOperator(face_dd, DD_VOLUME)(
-                    flux(sym.int_tpair(u, self.quad_tag))
-                    )
+        return (
+            op.inverse_mass(
+                dcoll,
+                sum(op.weak_local_d_dx(dcoll, quad_dd, d, quad_u * quad_v[d])
+                    for d in range(dcoll.ambient_dim))
+                - op.face_mass(
+                    dcoll,
+                    face_dd,
+                    sum(flux(quad_tpair)
+                        for quad_tpair in to_quad_int_tpairs(dcoll, u,
+                                                             self.quad_tag))
                 )
+            )
+        )
 
 # }}}
 
+
 # vim: foldmethod=marker
diff --git a/grudge/models/burgers.py b/grudge/models/burgers.py
deleted file mode 100644
index 0be0b6ffffe5027fd360d21fa999cceb99ca64ed..0000000000000000000000000000000000000000
--- a/grudge/models/burgers.py
+++ /dev/null
@@ -1,146 +0,0 @@
-"""Burgers operator."""
-
-__copyright__ = "Copyright (C) 2009 Andreas Kloeckner"
-
-__license__ = """
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-"""
-
-from grudge.models import HyperbolicOperator
-import numpy
-from grudge.second_order import CentralSecondDerivative
-
-
-class BurgersOperator(HyperbolicOperator):
-    def __init__(self, dimensions, viscosity=None,
-            viscosity_scheme=CentralSecondDerivative()):
-        # yes, you read that right--no BCs, 1D only.
-        # (well--you can run the operator on a 2D grid. If you must.)
-        self.dimensions = dimensions
-        self.viscosity = viscosity
-        self.viscosity_scheme = viscosity_scheme
-
-    def characteristic_velocity_optemplate(self, field):
-        from grudge.symbolic.operators import (
-                ElementwiseMaxOperator)
-        return ElementwiseMaxOperator()(field**2)**0.5
-
-    def bind_characteristic_velocity(self, discr):
-        from grudge.symbolic import Field
-        compiled = discr.compile(
-                self.characteristic_velocity_optemplate(
-                    Field("u")))
-
-        def do(u):
-            return compiled(u=u)
-
-        return do
-
-    def sym_operator(self, with_sensor):
-        from grudge.symbolic import (
-                Field,
-                make_stiffness_t,
-                make_nabla,
-                InverseMassOperator,
-                ElementwiseMaxOperator,
-                get_flux_operator)
-
-        from grudge.symbolic.operators import (
-                QuadratureGridUpsampler,
-                QuadratureInteriorFacesGridUpsampler)
-
-        to_quad = QuadratureGridUpsampler("quad")
-        to_if_quad = QuadratureInteriorFacesGridUpsampler("quad")
-
-        u = Field("u")
-        u0 = Field("u0")
-
-        # boundary conditions -------------------------------------------------
-        minv_st = make_stiffness_t(self.dimensions)
-        nabla = make_nabla(self.dimensions)
-        m_inv = InverseMassOperator()
-
-        def flux(u):
-            return u**2/2
-            #return u0*u
-
-        emax_u = self.characteristic_velocity_optemplate(u)
-        from grudge.flux.tools import make_lax_friedrichs_flux
-        from pytools.obj_array import make_obj_array
-        num_flux = make_lax_friedrichs_flux(
-                #u0,
-                to_if_quad(emax_u),
-                make_obj_array([to_if_quad(u)]),
-                [make_obj_array([flux(to_if_quad(u))])],
-                [], strong=False)[0]
-
-        from grudge.second_order import SecondDerivativeTarget
-
-        if self.viscosity is not None or with_sensor:
-            viscosity_coeff = 0
-            if with_sensor:
-                viscosity_coeff += Field("sensor")
-
-            if isinstance(self.viscosity, float):
-                viscosity_coeff += self.viscosity
-            elif self.viscosity is None:
-                pass
-            else:
-                raise TypeError("unsupported type of viscosity coefficient")
-
-            # strong_form here allows IPDG to reuse the value of grad u.
-            grad_tgt = SecondDerivativeTarget(
-                    self.dimensions, strong_form=True,
-                    operand=u)
-
-            self.viscosity_scheme.grad(grad_tgt, bc_getter=None,
-                    dirichlet_tags=[], neumann_tags=[])
-
-            div_tgt = SecondDerivativeTarget(
-                    self.dimensions, strong_form=False,
-                    operand=viscosity_coeff*grad_tgt.minv_all)
-
-            self.viscosity_scheme.div(div_tgt,
-                    bc_getter=None,
-                    dirichlet_tags=[], neumann_tags=[])
-
-            viscosity_bit = div_tgt.minv_all
-        else:
-            viscosity_bit = 0
-
-        return m_inv((minv_st[0](flux(to_quad(u)))) - num_flux) \
-                + viscosity_bit
-
-    def bind(self, discr, u0=1, sensor=None):
-        compiled_sym_operator = discr.compile(
-                self.sym_operator(with_sensor=sensor is not None))
-
-        from grudge.mesh import check_bc_coverage
-        check_bc_coverage(discr.mesh, [])
-
-        def rhs(t, u):
-            kwargs = {}
-            if sensor is not None:
-                kwargs["sensor"] = sensor(u)
-            return compiled_sym_operator(u=u, u0=u0, **kwargs)
-
-        return rhs
-
-    def max_eigenvalue(self, t=None, fields=None, discr=None):
-        return discr.nodewise_max(fields)
diff --git a/grudge/models/diffusion.py b/grudge/models/diffusion.py
deleted file mode 100644
index 4c2db682f92ce985121e140568732e166ca4c9e3..0000000000000000000000000000000000000000
--- a/grudge/models/diffusion.py
+++ /dev/null
@@ -1,121 +0,0 @@
-"""Operators modeling diffusive phenomena."""
-
-__copyright__ = "Copyright (C) 2009 Andreas Kloeckner"
-
-__license__ = """
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-"""
-
-import numpy
-
-import grudge.data
-from grudge.models import TimeDependentOperator
-from grudge.models.poisson import LaplacianOperatorBase
-from grudge.second_order import CentralSecondDerivative
-
-
-class DiffusionOperator(TimeDependentOperator, LaplacianOperatorBase):
-    def __init__(self, dimensions, diffusion_tensor=None,
-            dirichlet_bc=grudge.data.make_tdep_constant(0), dirichlet_tag="dirichlet",
-            neumann_bc=grudge.data.make_tdep_constant(0), neumann_tag="neumann",
-            scheme=CentralSecondDerivative()):
-        self.dimensions = dimensions
-
-        self.scheme = scheme
-
-        self.dirichlet_bc = dirichlet_bc
-        self.dirichlet_tag = dirichlet_tag
-        self.neumann_bc = neumann_bc
-        self.neumann_tag = neumann_tag
-
-        if diffusion_tensor is None:
-            diffusion_tensor = numpy.eye(dimensions)
-        self.diffusion_tensor = diffusion_tensor
-
-    def bind(self, discr):
-        """Return a :class:`BoundPoissonOperator`."""
-
-        assert self.dimensions == discr.dimensions
-
-        from grudge.mesh import check_bc_coverage
-        check_bc_coverage(discr.mesh, [self.dirichlet_tag, self.neumann_tag])
-
-        return BoundDiffusionOperator(self, discr)
-
-    def estimate_timestep(self, discr,
-            stepper=None, stepper_class=None, stepper_args=None,
-            t=None, fields=None):
-        """Estimate the largest stable timestep, given a time stepper
-        `stepper_class`. If none is given, RK4 is assumed.
-        """
-
-        rk4_dt = 0.2 \
-                * (discr.dt_non_geometric_factor()
-                * discr.dt_geometric_factor())**2
-
-        from grudge.timestep.stability import \
-                approximate_rk4_relative_imag_stability_region
-        return rk4_dt * approximate_rk4_relative_imag_stability_region(
-                stepper, stepper_class, stepper_args)
-
-
-
-
-class BoundDiffusionOperator(grudge.iterative.OperatorBase):
-    """Returned by :meth:`DiffusionOperator.bind`."""
-
-    def __init__(self, diffusion_op, discr):
-        grudge.iterative.OperatorBase.__init__(self)
-        self.discr = discr
-
-        dop = self.diffusion_op = diffusion_op
-
-        op = dop.sym_operator(apply_minv=True)
-
-        self.compiled_op = discr.compile(op)
-
-        # Check whether use of Poincaré mean-value method is required.
-        # (for pure Neumann or pure periodic)
-
-        from grudge.mesh import BTAG_ALL
-        self.poincare_mean_value_hack = (
-                len(self.discr.get_boundary(BTAG_ALL).nodes)
-                == len(self.discr.get_boundary(diffusion_op.neumann_tag).nodes))
-
-    def __call__(self, t, u):
-        dop = self.diffusion_op
-
-        context = {"u": u}
-
-        if not isinstance(self.diffusion_op.diffusion_tensor, numpy.ndarray):
-            self.diffusion = dop.diffusion_tensor.volume_interpolant(t, self.discr)
-            self.neu_diff = dop.diffusion_tensor.boundary_interpolant(
-                    t, self.discr, dop.neumann_tag)
-
-            context["diffusion"] = self.diffusion
-            context["neumann_diffusion"] = self.neu_diff
-
-        if not self.discr.get_boundary(dop.dirichlet_tag).is_empty():
-            context["dir_bc"] = dop.dirichlet_bc.boundary_interpolant(
-                    t, self.discr, dop.dirichlet_tag)
-        if not self.discr.get_boundary(dop.neumann_tag).is_empty():
-            context["neu_bc"] = dop.neumann_bc.boundary_interpolant(
-                    t, self.discr, dop.neumann_tag)
-
-        return self.compiled_op(**context)
diff --git a/grudge/models/em.py b/grudge/models/em.py
index 3f4bc13d42a017fcee36ba7b05237c512ca11451..44ec4a6edeccb7abc2d0a3bfe14f0673f8db8e14 100644
--- a/grudge/models/em.py
+++ b/grudge/models/em.py
@@ -4,6 +4,7 @@ __copyright__ = """
 Copyright (C) 2007-2017 Andreas Kloeckner
 Copyright (C) 2010 David Powell
 Copyright (C) 2017 Bogdan Enache
+Copyright (C) 2021 University of Illinois Board of Trustees
 """
 
 __license__ = """
@@ -26,13 +27,18 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 THE SOFTWARE.
 """
 
-from pytools import memoize_method
+
+from arraycontext.container.traversal import thaw
 
 from grudge.models import HyperbolicOperator
+
 from meshmode.mesh import BTAG_ALL, BTAG_NONE
-from grudge import sym
+
+from pytools import memoize_method
 from pytools.obj_array import flat_obj_array, make_obj_array
 
+import grudge.op as op
+
 
 class MaxwellOperator(HyperbolicOperator):
     """A strong-form 3D Maxwell operator which supports fixed or variable
@@ -43,7 +49,7 @@ class MaxwellOperator(HyperbolicOperator):
 
     _default_dimensions = 3
 
-    def __init__(self, epsilon, mu,
+    def __init__(self, dcoll, epsilon, mu,
             flux_type,
             bdry_flux_type=None,
             pec_tag=BTAG_ALL,
@@ -66,6 +72,7 @@ class MaxwellOperator(HyperbolicOperator):
             boundary condition
         """
 
+        self.dcoll = dcoll
         self.dimensions = dimensions or self._default_dimensions
 
         space_subset = [True]*self.dimensions + [False]*(3-self.dimensions)
@@ -103,7 +110,7 @@ class MaxwellOperator(HyperbolicOperator):
         self.current = current
         self.incident_bc_data = incident_bc
 
-    def flux(self, w):
+    def flux(self, wtpair):
         """The numerical flux for variable coefficients.
 
         :param flux_type: can be in [0,1] for anything between central and upwind,
@@ -112,10 +119,10 @@ class MaxwellOperator(HyperbolicOperator):
         As per Hesthaven and Warburton page 433.
         """
 
-        normal = sym.normal(w.dd, self.dimensions)
+        normal = thaw(op.normal(self.dcoll, wtpair.dd), self.dcoll._setup_actx)
 
         if self.fixed_material:
-            e, h = self.split_eh(w)
+            e, h = self.split_eh(wtpair)
             epsilon = self.epsilon
             mu = self.mu
 
@@ -168,13 +175,18 @@ class MaxwellOperator(HyperbolicOperator):
 
         e, h = self.split_eh(w)
 
-        nabla = sym.nabla(self.dimensions)
+        # Object array of derivative operators
+        nabla = flat_obj_array(
+            [_Dx(self.dcoll, i) for i in range(self.dimensions)]
+        )
 
         def e_curl(field):
-            return self.space_cross_e(nabla, field)
+            return self.space_cross_e(nabla, field,
+                                      three_mult=lambda lc, x, y: lc * (x * y))
 
         def h_curl(field):
-            return self.space_cross_h(nabla, field)
+            return self.space_cross_h(nabla, field,
+                                      three_mult=lambda lc, x, y: lc * (x * y))
 
         # in conservation form: u_t + A u_x = 0
         return flat_obj_array(
@@ -187,8 +199,8 @@ class MaxwellOperator(HyperbolicOperator):
         """
         e, h = self.split_eh(w)
 
-        pec_e = sym.cse(sym.project("vol", self.pec_tag)(e))
-        pec_h = sym.cse(sym.project("vol", self.pec_tag)(h))
+        pec_e = op.project(self.dcoll, "vol", self.pec_tag, e)
+        pec_h = op.project(self.dcoll, "vol", self.pec_tag, h)
 
         return flat_obj_array(-pec_e, pec_h)
 
@@ -197,8 +209,8 @@ class MaxwellOperator(HyperbolicOperator):
         """
         e, h = self.split_eh(w)
 
-        pmc_e = sym.cse(sym.project("vol", self.pmc_tag)(e))
-        pmc_h = sym.cse(sym.project("vol", self.pmc_tag)(h))
+        pmc_e = op.project(self.dcoll, "vol", self.pmc_tag, e)
+        pmc_h = op.project(self.dcoll, "vol", self.pmc_tag, h)
 
         return flat_obj_array(pmc_e, -pmc_h)
 
@@ -207,7 +219,8 @@ class MaxwellOperator(HyperbolicOperator):
         absorbing boundary conditions.
         """
 
-        absorb_normal = sym.normal(self.absorb_tag, self.dimensions)
+        absorb_normal = thaw(op.normal(self.dcoll, dd=self.absorb_tag),
+                             self.dcoll._setup_actx)
 
         e, h = self.split_eh(w)
 
@@ -218,8 +231,8 @@ class MaxwellOperator(HyperbolicOperator):
         absorb_Z = (mu/epsilon)**0.5  # noqa: N806
         absorb_Y = 1/absorb_Z  # noqa: N806
 
-        absorb_e = sym.cse(sym.project("vol", self.absorb_tag)(e))
-        absorb_h = sym.cse(sym.project("vol", self.absorb_tag)(h))
+        absorb_e = op.project(self.dcoll, "vol", self.absorb_tag, e)
+        absorb_h = op.project(self.dcoll, "vol", self.absorb_tag, h)
 
         bc = flat_obj_array(
                 absorb_e + 1/2*(self.space_cross_h(absorb_normal, self.space_cross_e(
@@ -247,9 +260,9 @@ class MaxwellOperator(HyperbolicOperator):
         if is_zero(incident_bc_data):
             return make_obj_array([0]*fld_cnt)
         else:
-            return sym.cse(-incident_bc_data)
+            return -incident_bc_data
 
-    def sym_operator(self, w=None):
+    def operator(self, t, w):
         """The full operator template - the high level description of
         the Maxwell operator.
 
@@ -257,7 +270,6 @@ class MaxwellOperator(HyperbolicOperator):
         derivatives, flux, boundary conditions etc.
         """
         from grudge.tools import count_subset
-        w = sym.make_sym_array("w", count_subset(self.get_eh_subset()))
 
         elec_components = count_subset(self.get_eh_subset()[0:3])
         mag_components = count_subset(self.get_eh_subset()[3:6])
@@ -274,17 +286,23 @@ class MaxwellOperator(HyperbolicOperator):
                 (self.incident_tag, self.incident_bc(w)),
                 ]
 
+        dcoll = self.dcoll
+
         def flux(pair):
-            return sym.project(pair.dd, "all_faces")(self.flux(pair))
+            return op.project(dcoll, pair.dd, "all_faces", self.flux(pair))
 
         return (
-                - self.local_derivatives(w)
-                - sym.InverseMassOperator()(sym.FaceMassOperator()(
-                    flux(sym.int_tpair(w))
-                    + sum(
-                        flux(sym.bv_tpair(tag, w, bc))
-                        for tag, bc in tags_and_bcs)
-                    ))) / material_divisor
+            - self.local_derivatives(w)
+            - op.inverse_mass(
+                dcoll,
+                op.face_mass(
+                    dcoll,
+                    sum(flux(tpair) for tpair in op.interior_trace_pairs(dcoll, w))
+                    + sum(flux(op.bv_trace_pair(dcoll, tag, w, bc))
+                          for tag, bc in tags_and_bcs)
+                )
+            )
+        ) / material_divisor
 
     @memoize_method
     def partial_to_eh_subsets(self):
@@ -322,10 +340,9 @@ class MaxwellOperator(HyperbolicOperator):
         if self.fixed_material:
             return 1/sqrt(self.epsilon*self.mu)  # a number
         else:
-            import grudge.symbolic as sym
-            return sym.NodalMax("vol")(
-                    1 / sym.sqrt(self.epsilon * self.mu)
-                    )
+            actx = self.dcoll._setup_actx
+            return op.nodal_max(self.dcoll, "vol",
+                                1 / actx.np.sqrt(self.epsilon * self.mu))
 
     def max_eigenvalue(self, t, fields=None, discr=None, context=None):
         if context is None:
@@ -345,6 +362,17 @@ class MaxwellOperator(HyperbolicOperator):
             self.incident_tag])
 
 
+# NOTE: Hack for getting the derivative operators to play nice
+# with grudge.tools.SubsettableCrossProduct
+class _Dx:
+    def __init__(self, dcoll, i):
+        self.dcoll = dcoll
+        self.i = i
+
+    def __mul__(self, other):
+        return op.local_d_dx(self.dcoll, self.i, other)
+
+
 class TMMaxwellOperator(MaxwellOperator):
     """A 2D TM Maxwell operator with PEC boundaries.
 
@@ -405,7 +433,7 @@ class SourceFree1DMaxwellOperator(MaxwellOperator):
                 )
 
 
-def get_rectangular_cavity_mode(E_0, mode_indices):  # noqa: N803
+def get_rectangular_cavity_mode(actx, nodes, t, E_0, mode_indices):  # noqa: N803
     """A rectangular TM cavity mode for a rectangle / cube
     with one corner at the origin and the other at (1,1[,1])."""
     dims = len(mode_indices)
@@ -421,43 +449,44 @@ def get_rectangular_cavity_mode(E_0, mode_indices):  # noqa: N803
 
     omega = numpy.sqrt(sum(f**2 for f in factors))
 
-    nodes = sym.nodes(dims)
     x = nodes[0]
     y = nodes[1]
     if dims == 3:
         z = nodes[2]
 
-    sx = sym.sin(kx*x)
-    cx = sym.cos(kx*x)
-    sy = sym.sin(ky*y)
-    cy = sym.cos(ky*y)
+    zeros = 0*x
+    sx = actx.np.sin(kx*x)
+    cx = actx.np.cos(kx*x)
+    sy = actx.np.sin(ky*y)
+    cy = actx.np.cos(ky*y)
     if dims == 3:
-        sz = sym.sin(kz*z)
-        cz = sym.cos(kz*z)
+        sz = actx.np.sin(kz*z)
+        cz = actx.np.cos(kz*z)
 
     if dims == 2:
-        tfac = sym.ScalarVariable("t") * omega
+        tfac = t * omega
 
         result = flat_obj_array(
-            0,
-            0,
-            sym.sin(kx * x) * sym.sin(ky * y) * sym.cos(tfac),  # ez
-            -ky * sym.sin(kx * x) * sym.cos(ky * y) * sym.sin(tfac) / omega,  # hx
-            kx * sym.cos(kx * x) * sym.sin(ky * y) * sym.sin(tfac) / omega,  # hy
-            0,
-            )
+            zeros,
+            zeros,
+            actx.np.sin(kx * x) * actx.np.sin(ky * y) * actx.np.cos(tfac),  # ez
+            (-ky * actx.np.sin(kx * x) * actx.np.cos(ky * y)
+             * actx.np.sin(tfac) / omega),  # hx
+            (kx * actx.np.cos(kx * x) * actx.np.sin(ky * y)
+             * actx.np.sin(tfac) / omega),  # hy
+            zeros,
+        )
     else:
-        tdep = sym.exp(-1j * omega * sym.ScalarVariable("t"))
+        tdep = numpy.exp(-1j * omega * t)
 
         gamma_squared = ky**2 + kx**2
         result = flat_obj_array(
             -kx * kz * E_0*cx*sy*sz*tdep / gamma_squared,  # ex
             -ky * kz * E_0*sx*cy*sz*tdep / gamma_squared,  # ey
             E_0 * sx*sy*cz*tdep,  # ez
-
             -1j * omega * ky*E_0*sx*cy*cz*tdep / gamma_squared,  # hx
             1j * omega * kx*E_0*cx*sy*cz*tdep / gamma_squared,
-            0,
-            )
+            zeros,
+        )
 
     return result
diff --git a/grudge/models/gas_dynamics/__init__.py b/grudge/models/gas_dynamics/__init__.py
deleted file mode 100644
index 7562bae3148bdf6e4d3833a737b4939c52a595ff..0000000000000000000000000000000000000000
--- a/grudge/models/gas_dynamics/__init__.py
+++ /dev/null
@@ -1,914 +0,0 @@
-"""Operator for compressible Navier-Stokes and Euler equations."""
-
-__copyright__ = "Copyright (C) 2007 Hendrik Riedmann, Andreas Kloeckner"
-
-__license__ = """
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-"""
-
-
-import numpy
-import grudge.tools
-import grudge.mesh
-import grudge.data
-from grudge.models import TimeDependentOperator
-from pytools import Record
-from grudge.tools import is_zero
-from grudge.second_order import (
-        StabilizedCentralSecondDerivative,
-        CentralSecondDerivative,
-        IPDGSecondDerivative)
-from grudge.symbolic.primitives import make_common_subexpression as cse
-from pytools import memoize_method
-from grudge.symbolic.tools import make_sym_vector
-from pytools.obj_array import make_obj_array, join_fields
-
-AXES = ["x", "y", "z", "w"]
-
-from grudge.symbolic.operators import (
-        QuadratureGridUpsampler,
-        QuadratureInteriorFacesGridUpsampler)
-
-to_vol_quad = QuadratureGridUpsampler("gasdyn_vol")
-
-# It is recommended (though not required) that these two
-# remain the same so that they can be computed together
-# by the CUDA backend
-
-to_int_face_quad = QuadratureInteriorFacesGridUpsampler("gasdyn_face")
-to_bdry_quad = QuadratureGridUpsampler("gasdyn_face")
-
-
-
-
-# {{{ equations of state
-class EquationOfState:
-    def q_to_p(self, op, q):
-        raise NotImplementedError
-
-    def p_to_e(self, p, rho, u):
-        raise NotImplementedError
-
-class GammaLawEOS(EquationOfState):
-    # FIXME Shouldn't gamma only occur in the equation of state?
-    # I.e. shouldn't all uses of gamma go through the EOS?
-
-    def __init__(self, gamma):
-        self.gamma = gamma
-
-    def q_to_p(self, op, q):
-        return (self.gamma-1)*(op.e(q)-0.5*numpy.dot(op.rho_u(q), op.u(q)))
-
-    def p_to_e(self, p, rho, u):
-        return p / (self.gamma - 1) + rho / 2 * numpy.dot(u, u)
-
-class PolytropeEOS(GammaLawEOS):
-    # inverse is same as superclass
-
-    def q_to_p(self, op, q):
-        return  op.rho(q)**self.gamma
-
-# }}}
-
-
-
-
-
-class GasDynamicsOperator(TimeDependentOperator):
-    """An nD Navier-Stokes and Euler operator.
-
-    see JSH, TW: Nodal Discontinuous Galerkin Methods p.320 and p.206
-
-    dq/dt = d/dx * (-F + tau_:1) + d/dy * (-G + tau_:2)
-
-    where e.g. in 2D
-
-    q = (rho, rho_u_x, rho_u_y, E)
-    F = (rho_u_x, rho_u_x^2 + p, rho_u_x * rho_u_y / rho, u_x * (E + p))
-    G = (rho_u_y, rho_u_x * rho_u_y / rho, rho_u_y^2 + p, u_y * (E + p))
-
-    tau_11 = mu * (2 * du/dx - 2/3 * (du/dx + dv/dy))
-    tau_12 = mu * (du/dy + dv/dx)
-    tau_21 = tau_12
-    tau_22 = mu * (2 * dv/dy - 2/3 * (du/dx + dv/dy))
-    tau_31 = u * tau_11 + v * tau_12
-    tau_32 = u * tau_21 + v * tau_22
-
-    For the heat flux:
-
-    q = -k * nabla * T
-    k = c_p * mu / Pr
-
-    Field order is [rho E rho_u_x rho_u_y ...].
-    """
-
-    # {{{ initialization ------------------------------------------------------
-    def __init__(self, dimensions,
-            gamma=None, mu=0,
-            bc_inflow=None,
-            bc_outflow=None,
-            bc_noslip=None,
-            bc_supersonic_inflow=None,
-            prandtl=None, spec_gas_const=1.0,
-            equation_of_state=None,
-            inflow_tag="inflow",
-            outflow_tag="outflow",
-            noslip_tag="noslip",
-            wall_tag="wall",
-            supersonic_inflow_tag="supersonic_inflow",
-            supersonic_outflow_tag="supersonic_outflow",
-            source=None,
-            second_order_scheme=CentralSecondDerivative(),
-            artificial_viscosity_mode=None,
-            ):
-        """
-        :param source: should implement
-          :class:`grudge.data.IFieldDependentGivenFunction`
-          or be None.
-
-        :param artificial_viscosity_mode:
-        """
-        from grudge.data import (
-                TimeConstantGivenFunction,
-                ConstantGivenFunction)
-
-        if gamma is not None:
-            if equation_of_state is not None:
-                raise ValueError("can only specify one of gamma and equation_of_state")
-
-            from warnings import warn
-            warn("argument gamma is deprecated in favor of equation_of_state",
-                    DeprecationWarning, stacklevel=2)
-
-            equation_of_state = GammaLawEOS(gamma)
-
-        dull_bc = TimeConstantGivenFunction(
-                ConstantGivenFunction(make_obj_array(
-                    [1, 1] + [0]*dimensions)))
-        if bc_inflow is None:
-            bc_inflow = dull_bc
-        if bc_outflow is None:
-            bc_outflow = dull_bc
-        if bc_noslip is None:
-            bc_noslip = dull_bc
-        if bc_supersonic_inflow is None:
-            bc_supersonic_inflow = dull_bc
-
-        self.dimensions = dimensions
-
-        self.prandtl = prandtl
-        self.spec_gas_const = spec_gas_const
-        self.mu = mu
-
-        self.bc_inflow = bc_inflow
-        self.bc_outflow = bc_outflow
-        self.bc_noslip = bc_noslip
-        self.bc_supersonic_inflow = bc_supersonic_inflow
-
-        self.inflow_tag = inflow_tag
-        self.outflow_tag = outflow_tag
-        self.noslip_tag = noslip_tag
-        self.wall_tag = wall_tag
-        self.supersonic_inflow_tag = supersonic_inflow_tag
-        self.supersonic_outflow_tag = supersonic_outflow_tag
-
-        self.source = source
-        self.equation_of_state = equation_of_state
-
-        self.second_order_scheme = second_order_scheme
-
-        if artificial_viscosity_mode not in [
-                "cns", "diffusion", "blended", None]:
-            raise ValueError("artificial_viscosity_mode has an invalid value")
-
-        self.artificial_viscosity_mode = artificial_viscosity_mode
-
-
-
-    # }}}
-
-    # {{{ conversions ---------------------------------------------------------
-    def state(self):
-        return make_sym_vector("q", self.dimensions+2)
-
-    @memoize_method
-    def volq_state(self):
-        return cse(to_vol_quad(self.state()), "vol_quad_state")
-
-    @memoize_method
-    def faceq_state(self):
-        return cse(to_int_face_quad(self.state()), "face_quad_state")
-
-    @memoize_method
-    def sensor(self):
-        from grudge.symbolic.primitives import Field
-        sensor = Field("sensor")
-
-    def rho(self, q):
-        return q[0]
-
-    def e(self, q):
-        return q[1]
-
-    def rho_u(self, q):
-        return q[2:2+self.dimensions]
-
-    def u(self, q):
-        return make_obj_array([
-                rho_u_i/self.rho(q)
-                for rho_u_i in self.rho_u(q)])
-
-    def p(self,q):
-        return self.equation_of_state.q_to_p(self, q)
-
-    def cse_u(self, q):
-        return cse(self.u(q), "u")
-
-    def cse_rho(self, q):
-        return cse(self.rho(q), "rho")
-
-    def cse_rho_u(self, q):
-        return cse(self.rho_u(q), "rho_u")
-
-    def cse_p(self, q):
-        return cse(self.p(q), "p")
-
-    def temperature(self, q):
-        c_v = 1 / (self.equation_of_state.gamma - 1) * self.spec_gas_const
-        return (self.e(q)/self.rho(q) - 0.5 * numpy.dot(self.u(q), self.u(q))) / c_v
-
-    def cse_temperature(self, q):
-        return cse(self.temperature(q), "temperature")
-
-    def get_mu(self, q, to_quad_op):
-        r"""
-        :param to_quad_op: If not *None*, represents an operator which transforms
-          nodal values onto a quadrature grid on which the returned :math:`\mu`
-          needs to be represented. In that case, *q* is assumed to already be on the
-          same quadrature grid.
-        """
-
-        if to_quad_op is None:
-            def to_quad_op(x):
-                return x
-
-        if self.mu == "sutherland":
-            # Sutherland's law: !!!not tested!!!
-            t_s = 110.4
-            mu_inf = 1.735e-5
-            result = cse(
-                    mu_inf * self.cse_temperature(q) ** 1.5 * (1 + t_s)
-                    / (self.cse_temperature(q) + t_s),
-                    "sutherland_mu")
-        else:
-            result = self.mu
-
-        if self.artificial_viscosity_mode == "cns":
-            mapped_sensor = self.sensor()
-        else:
-            mapped_sensor = None
-
-        if mapped_sensor is not None:
-            result = result + cse(to_quad_op(mapped_sensor), "quad_sensor")
-
-        return cse(result, "mu")
-
-    def primitive_to_conservative(self, prims, use_cses=True):
-        if not use_cses:
-            from grudge.symbolic.primitives import make_common_subexpression as cse
-        else:
-            def cse(x, name): return x
-
-        rho = prims[0]
-        p = prims[1]
-        u = prims[2:]
-        e = self.equation_of_state.p_to_e(p, rho, u)
-
-        return join_fields(
-               rho,
-               cse(e, "e"),
-               cse(rho * u, "rho_u"))
-
-    def conservative_to_primitive(self, q, use_cses=True):
-        if use_cses:
-            from grudge.symbolic.primitives import make_common_subexpression as cse
-        else:
-            def cse(x, name): return x
-
-        return join_fields(
-               self.rho(q),
-               self.p(q),
-               self.u(q))
-
-    def characteristic_velocity_optemplate(self, state):
-        from grudge.symbolic.operators import ElementwiseMaxOperator
-
-        from grudge.symbolic.primitives import FunctionSymbol
-        sqrt = FunctionSymbol("sqrt")
-
-        sound_speed = cse(sqrt(
-            self.equation_of_state.gamma*self.cse_p(state)/self.cse_rho(state)),
-            "sound_speed")
-        u = self.cse_u(state)
-        speed = cse(sqrt(numpy.dot(u, u)), "norm_u") + sound_speed
-        return ElementwiseMaxOperator()(speed)
-
-    def bind_characteristic_velocity(self, discr):
-        state = make_sym_vector("q", self.dimensions+2)
-
-        compiled = discr.compile(
-                self.characteristic_velocity_optemplate(state))
-
-        def do(q):
-            return compiled(q=q)
-
-        return do
-
-    # }}}
-
-    # {{{ helpers for second-order part ---------------------------------------
-
-    # {{{ compute gradient of state ---------------------------------------
-    def grad_of(self, var, faceq_var):
-        from grudge.second_order import SecondDerivativeTarget
-        grad_tgt = SecondDerivativeTarget(
-                self.dimensions, strong_form=False,
-                operand=var,
-                int_flux_operand=faceq_var,
-                bdry_flux_int_operand=faceq_var)
-
-        self.second_order_scheme.grad(grad_tgt,
-                bc_getter=self.get_boundary_condition_for,
-                dirichlet_tags=self.get_boundary_tags(),
-                neumann_tags=[])
-
-        return grad_tgt.minv_all
-
-    def grad_of_state(self):
-        dimensions = self.dimensions
-
-        state = self.state()
-
-        dq = numpy.zeros((len(state), dimensions), dtype=object)
-
-        for i in range(len(state)):
-            dq[i,:] = self.grad_of(
-                    state[i], self.faceq_state()[i])
-
-        return dq
-
-    def grad_of_state_func(self, func, of_what_descr):
-        return cse(self.grad_of(
-            func(self.volq_state()),
-            func(self.faceq_state())),
-            "grad_"+of_what_descr)
-
-    # }}}
-
-    # {{{ viscous stress tensor
-
-    def tau(self, to_quad_op, state, mu=None):
-        faceq_state = self.faceq_state()
-
-        dimensions = self.dimensions
-
-        # {{{ compute gradient of u ---------------------------------------
-        # Use the product rule to compute the gradient of
-        # u from the gradient of (rho u). This ensures we don't
-        # compute the derivatives twice.
-
-        from pytools.obj_array import with_object_array_or_scalar
-        dq = with_object_array_or_scalar(
-                to_quad_op, self.grad_of_state())
-
-        q = cse(to_quad_op(state))
-
-        du = numpy.zeros((dimensions, dimensions), dtype=object)
-        for i in range(dimensions):
-            for j in range(dimensions):
-                du[i,j] = cse(
-                        (dq[i+2,j] - self.cse_u(q)[i] * dq[0,j]) / self.rho(q),
-                        "du%d_d%s" % (i, AXES[j]))
-
-        # }}}
-
-        # {{{ put together viscous stress tau -----------------------------
-        from pytools import delta
-
-        if mu is None:
-            mu = self.get_mu(q, to_quad_op)
-
-        tau = numpy.zeros((dimensions, dimensions), dtype=object)
-        for i in range(dimensions):
-            for j in range(dimensions):
-                tau[i,j] = cse(mu * cse(du[i,j] + du[j,i] -
-                           2/self.dimensions * delta(i,j) * numpy.trace(du)),
-                           "tau_%d%d" % (i, j))
-
-        return tau
-
-        # }}}
-
-    # }}}
-
-    # }}}
-
-    # {{{ heat conduction
-
-    def heat_conduction_coefficient(self, to_quad_op):
-        mu = self.get_mu(self.state(), to_quad_op)
-        if self.prandtl is None or numpy.isinf(self.prandtl):
-            return 0
-
-        eos = self.equation_of_state
-        return (mu / self.prandtl) * (eos.gamma / (eos.gamma-1))
-
-    def heat_conduction_grad(self, to_quad_op):
-        grad_p_over_rho = self.grad_of_state_func(
-                lambda state: self.p(state)/self.rho(state),
-                "p_over_rho")
-
-        return (self.heat_conduction_coefficient(to_quad_op)
-                * to_quad_op(grad_p_over_rho))
-
-    # }}}
-
-    # {{{ flux
-
-    def flux(self, q):
-        from pytools import delta
-
-        return [ # one entry for each flux direction
-                cse(join_fields(
-                    # flux rho
-                    self.rho_u(q)[i],
-
-                    # flux E
-                    cse(self.e(q)+self.cse_p(q))*self.cse_u(q)[i],
-
-                    # flux rho_u
-                    make_obj_array([
-                        self.rho_u(q)[i]*self.cse_u(q)[j]
-                        + delta(i,j) * self.cse_p(q)
-                        for j in range(self.dimensions)
-                        ])
-                    ), "%s_flux" % AXES[i])
-                for i in range(self.dimensions)]
-
-    # }}}
-
-    # {{{ boundary conditions ---------------------------------------------
-
-    def make_bc_info(self, bc_name, tag, state, state0=None):
-        """
-        :param state0: The boundary 'free-stream' state around which the
-          BC is linearized.
-        """
-        if state0 is None:
-            state0 = make_sym_vector(bc_name, self.dimensions+2)
-
-        state0 = cse(to_bdry_quad(state0))
-
-        rho0 = self.rho(state0)
-        p0 = self.cse_p(state0)
-        u0 = self.cse_u(state0)
-
-        c0 = (self.equation_of_state.gamma * p0 / rho0)**0.5
-
-        from grudge.symbolic import RestrictToBoundary
-        bdrize_op = RestrictToBoundary(tag)
-
-        class SingleBCInfo(Record):
-            pass
-
-        return SingleBCInfo(
-            rho0=rho0, p0=p0, u0=u0, c0=c0,
-
-            # notation: suffix "m" for "minus", i.e. "interior"
-            drhom=cse(self.rho(cse(to_bdry_quad(bdrize_op(state))))
-                - rho0, "drhom"),
-            dumvec=cse(self.cse_u(cse(to_bdry_quad(bdrize_op(state))))
-                - u0, "dumvec"),
-            dpm=cse(self.cse_p(cse(to_bdry_quad(bdrize_op(state))))
-                - p0, "dpm"))
-
-    def outflow_state(self, state):
-        from grudge.symbolic import make_normal
-        normal = make_normal(self.outflow_tag, self.dimensions)
-        bc = self.make_bc_info("bc_q_out", self.outflow_tag, state)
-
-        # see grudge/doc/maxima/euler.mac
-        return join_fields(
-            # bc rho
-            cse(bc.rho0
-            + bc.drhom + numpy.dot(normal, bc.dumvec)*bc.rho0/(2*bc.c0)
-            - bc.dpm/(2*bc.c0*bc.c0), "bc_rho_outflow"),
-
-            # bc p
-            cse(bc.p0
-            + bc.c0*bc.rho0*numpy.dot(normal, bc.dumvec)/2 + bc.dpm/2, "bc_p_outflow"),
-
-            # bc u
-            cse(bc.u0
-            + bc.dumvec - normal*numpy.dot(normal, bc.dumvec)/2
-            + bc.dpm*normal/(2*bc.c0*bc.rho0), "bc_u_outflow"))
-
-    def inflow_state_inner(self, normal, bc, name):
-        # see grudge/doc/maxima/euler.mac
-        return join_fields(
-            # bc rho
-            cse(bc.rho0
-            + numpy.dot(normal, bc.dumvec)*bc.rho0/(2*bc.c0) + bc.dpm/(2*bc.c0*bc.c0), "bc_rho_"+name),
-
-            # bc p
-            cse(bc.p0
-            + bc.c0*bc.rho0*numpy.dot(normal, bc.dumvec)/2 + bc.dpm/2, "bc_p_"+name),
-
-            # bc u
-            cse(bc.u0
-            + normal*numpy.dot(normal, bc.dumvec)/2 + bc.dpm*normal/(2*bc.c0*bc.rho0), "bc_u_"+name))
-
-    def inflow_state(self, state):
-        from grudge.symbolic import make_normal
-        normal = make_normal(self.inflow_tag, self.dimensions)
-        bc = self.make_bc_info("bc_q_in", self.inflow_tag, state)
-        return self.inflow_state_inner(normal, bc, "inflow")
-
-    def noslip_state(self, state):
-        from grudge.symbolic import make_normal
-        state0 = join_fields(
-            make_sym_vector("bc_q_noslip", 2),
-            [0]*self.dimensions)
-        normal = make_normal(self.noslip_tag, self.dimensions)
-        bc = self.make_bc_info("bc_q_noslip", self.noslip_tag, state, state0)
-        return self.inflow_state_inner(normal, bc, "noslip")
-
-    def wall_state(self, state):
-        from grudge.symbolic import RestrictToBoundary
-        bc = RestrictToBoundary(self.wall_tag)(state)
-        wall_rho = self.rho(bc)
-        wall_e = self.e(bc) # <3 eve
-        wall_rho_u = self.rho_u(bc)
-
-        from grudge.symbolic import make_normal
-        normal = make_normal(self.wall_tag, self.dimensions)
-
-        return join_fields(
-                wall_rho,
-                wall_e,
-                wall_rho_u - 2*numpy.dot(wall_rho_u, normal) * normal)
-
-    @memoize_method
-    def get_primitive_boundary_conditions(self):
-        state = self.state()
-
-        return {
-                self.outflow_tag: self.outflow_state(state),
-                self.inflow_tag: self.inflow_state(state),
-                self.noslip_tag: self.noslip_state(state)
-                }
-
-
-    @memoize_method
-    def get_conservative_boundary_conditions(self):
-        state = self.state()
-
-        from grudge.symbolic import RestrictToBoundary
-        return {
-                self.supersonic_inflow_tag:
-                make_sym_vector("bc_q_supersonic_in", self.dimensions+2),
-                self.supersonic_outflow_tag:
-                RestrictToBoundary(self.supersonic_outflow_tag)(
-                            state),
-                self.wall_tag: self.wall_state(state),
-                }
-
-    @memoize_method
-    def get_boundary_tags(self):
-        return (set(self.get_primitive_boundary_conditions().keys())
-                | set(self.get_conservative_boundary_conditions().keys()))
-
-    @memoize_method
-    def _normalize_expr(self, expr):
-        """Normalize expressions for use as hash keys."""
-        from grudge.symbolic.mappers import (
-                QuadratureUpsamplerRemover,
-                CSERemover)
-
-        return CSERemover()(
-                QuadratureUpsamplerRemover({}, do_warn=False)(expr))
-
-    @memoize_method
-    def _get_norm_primitive_exprs(self):
-        return [
-                self._normalize_expr(expr) for expr in
-                self.conservative_to_primitive(self.state())
-                ]
-
-    @memoize_method
-    def get_boundary_condition_for(self, tag, expr):
-        prim_bcs = self.get_primitive_boundary_conditions()
-        cons_bcs = self.get_conservative_boundary_conditions()
-
-        if tag in prim_bcs:
-            # BC is given in primitive variables, avoid converting
-            # to conservative and back.
-            try:
-                norm_expr = self._normalize_expr(expr)
-                prim_idx = self._get_norm_primitive_exprs().index(norm_expr)
-            except ValueError:
-                cbstate = self.primitive_to_conservative(
-                        prim_bcs[tag])
-            else:
-                return prim_bcs[tag][prim_idx]
-        else:
-            # BC is given in conservative variables, no potential
-            # for optimization.
-
-            cbstate = to_bdry_quad(cons_bcs[tag])
-
-        # 'cbstate' is the boundary state in conservative variables.
-
-        from grudge.symbolic.mappers import QuadratureUpsamplerRemover
-        expr = QuadratureUpsamplerRemover({}, do_warn=False)(expr)
-
-        def subst_func(expr):
-            from pymbolic.primitives import Subscript, Variable
-
-            if isinstance(expr, Subscript):
-                assert (isinstance(expr.aggregate, Variable)
-                        and expr.aggregate.name == "q")
-
-                return cbstate[expr.index]
-            elif isinstance(expr, Variable) and expr.name =="sensor":
-                from grudge.symbolic import RestrictToBoundary
-                result = RestrictToBoundary(tag)(self.sensor())
-                return cse(to_bdry_quad(result), "bdry_sensor")
-
-        from grudge.symbolic.mappers import SubstitutionMapper
-        return SubstitutionMapper(subst_func)(expr)
-
-    # }}}
-
-    # {{{ second order part
-    def div(self, vol_operand, int_face_operand):
-        from grudge.second_order import SecondDerivativeTarget
-        div_tgt = SecondDerivativeTarget(
-                self.dimensions, strong_form=False,
-                operand=vol_operand,
-                int_flux_operand=int_face_operand)
-
-        self.second_order_scheme.div(div_tgt,
-                bc_getter=self.get_boundary_condition_for,
-                dirichlet_tags=list(self.get_boundary_tags()),
-                neumann_tags=[])
-
-        return div_tgt.minv_all
-
-    def make_second_order_part(self):
-        state = self.state()
-        faceq_state = self.faceq_state()
-        volq_state = self.volq_state()
-
-        volq_tau_mat = self.tau(to_vol_quad, state)
-        faceq_tau_mat = self.tau(to_int_face_quad, state)
-
-        return join_fields(
-                0,
-                self.div(
-                    numpy.sum(volq_tau_mat*self.cse_u(volq_state), axis=1)
-                    + self.heat_conduction_grad(to_vol_quad)
-                    ,
-                    numpy.sum(faceq_tau_mat*self.cse_u(faceq_state), axis=1)
-                    + self.heat_conduction_grad(to_int_face_quad)
-                    ,
-                    ),
-                [
-                    self.div(volq_tau_mat[i], faceq_tau_mat[i])
-                    for i in range(self.dimensions)]
-                )
-
-    # }}}
-
-    # {{{ operator template ---------------------------------------------------
-    def make_extra_terms(self):
-        return 0
-
-    def sym_operator(self, sensor_scaling=None, viscosity_only=False):
-        u = self.cse_u
-        rho = self.cse_rho
-        rho_u = self.rho_u
-        p = self.p
-        e = self.e
-
-        # {{{ artificial diffusion
-        def make_artificial_diffusion():
-            if self.artificial_viscosity_mode not in ["diffusion"]:
-                return 0
-
-            dq = self.grad_of_state()
-
-            return make_obj_array([
-                self.div(
-                    to_vol_quad(self.sensor())*to_vol_quad(dq[i]),
-                    to_int_face_quad(self.sensor())*to_int_face_quad(dq[i]))
-                for i in range(dq.shape[0])])
-        # }}}
-
-        # {{{ state setup
-
-        volq_flux = self.flux(self.volq_state())
-        faceq_flux = self.flux(self.faceq_state())
-
-        from grudge.symbolic.primitives import FunctionSymbol
-        sqrt = FunctionSymbol("sqrt")
-
-        speed = self.characteristic_velocity_optemplate(self.state())
-
-        has_viscosity = not is_zero(self.get_mu(self.state(), to_quad_op=None))
-
-        # }}}
-
-        # {{{ operator assembly -----------------------------------------------
-        from grudge.flux.tools import make_lax_friedrichs_flux
-        from grudge.symbolic.operators import InverseMassOperator
-
-        from grudge.symbolic.tools import make_stiffness_t
-
-        primitive_bcs_as_quad_conservative = {
-                tag: self.primitive_to_conservative(to_bdry_quad(bc))
-                for tag, bc in
-                self.get_primitive_boundary_conditions().items()}
-
-        def get_bc_tuple(tag):
-            state = self.state()
-            bc = make_obj_array([
-                self.get_boundary_condition_for(tag, s_i) for s_i in state])
-            return tag, bc, self.flux(bc)
-
-        first_order_part = InverseMassOperator()(
-                numpy.dot(make_stiffness_t(self.dimensions), volq_flux)
-                - make_lax_friedrichs_flux(
-                    wave_speed=cse(to_int_face_quad(speed), "emax_c"),
-
-                    state=self.faceq_state(), fluxes=faceq_flux,
-                    bdry_tags_states_and_fluxes=[
-                        get_bc_tuple(tag) for tag in self.get_boundary_tags()],
-                    strong=False))
-
-        if viscosity_only:
-            first_order_part = 0*first_order_part
-
-        result = join_fields(
-                first_order_part
-                + self.make_second_order_part()
-                + make_artificial_diffusion()
-                + self.make_extra_terms(),
-                 speed)
-
-        if self.source is not None:
-            result = result + join_fields(
-                    make_sym_vector("source_vect", len(self.state())),
-                    # extra field for speed
-                    0)
-
-        return result
-
-        # }}}
-
-    # }}}
-
-    # {{{ operator binding ----------------------------------------------------
-    def bind(self, discr, sensor=None, sensor_scaling=None, viscosity_only=False):
-        if (sensor is None and
-                self.artificial_viscosity_mode is not None):
-            raise ValueError("must specify a sensor if using "
-                    "artificial viscosity")
-
-        bound_op = discr.compile(self.sym_operator(
-            sensor_scaling=sensor_scaling,
-            viscosity_only=False))
-
-        from grudge.mesh import check_bc_coverage
-        check_bc_coverage(discr.mesh, [
-            self.inflow_tag,
-            self.outflow_tag,
-            self.noslip_tag,
-            self.wall_tag,
-            self.supersonic_inflow_tag,
-            self.supersonic_outflow_tag,
-            ])
-
-        if self.mu == 0 and not discr.get_boundary(self.noslip_tag).is_empty():
-            raise RuntimeError("no-slip BCs only make sense for "
-                    "viscous problems")
-
-        def rhs(t, q):
-            extra_kwargs = {}
-            if self.source is not None:
-                extra_kwargs["source_vect"] = self.source.volume_interpolant(
-                        t, q, discr)
-
-            if sensor is not None:
-                extra_kwargs["sensor"] = sensor(q)
-
-            opt_result = bound_op(q=q,
-                    bc_q_in=self.bc_inflow.boundary_interpolant(
-                        t, discr, self.inflow_tag),
-                    bc_q_out=self.bc_outflow.boundary_interpolant(
-                        t, discr, self.outflow_tag),
-                    bc_q_noslip=self.bc_noslip.boundary_interpolant(
-                        t, discr, self.noslip_tag),
-                    bc_q_supersonic_in=self.bc_supersonic_inflow
-                    .boundary_interpolant(t, discr,
-                        self.supersonic_inflow_tag),
-                    **extra_kwargs
-                    )
-
-            max_speed = opt_result[-1]
-            ode_rhs = opt_result[:-1]
-            return ode_rhs, discr.nodewise_max(max_speed)
-
-        return rhs
-
-    # }}}
-
-    # {{{ timestep estimation -------------------------------------------------
-
-    def estimate_timestep(self, discr,
-            stepper=None, stepper_class=None, stepper_args=None,
-            t=None, max_eigenvalue=None):
-        """Estimate the largest stable timestep, given a time stepper
-        `stepper_class`. If none is given, RK4 is assumed.
-        """
-
-        dg_factor = (discr.dt_non_geometric_factor()
-                * discr.dt_geometric_factor())
-
-        # see JSH/TW, eq. (7.32)
-        rk4_dt = dg_factor / (max_eigenvalue + self.mu / dg_factor)
-
-        from grudge.timestep.stability import \
-                approximate_rk4_relative_imag_stability_region
-        return rk4_dt * approximate_rk4_relative_imag_stability_region(
-                stepper, stepper_class, stepper_args)
-
-    # }}}
-
-
-
-
-# {{{ limiter (unfinished, deprecated)
-class SlopeLimiter1NEuler:
-    def __init__(self, discr, gamma, dimensions, op):
-        """Construct a limiter from Jan's book page 225
-        """
-        self.discr = discr
-        self.gamma=gamma
-        self.dimensions=dimensions
-        self.op=op
-
-        from grudge.symbolic.operators import AveragingOperator
-        self.get_average = AveragingOperator().bind(discr)
-
-    def __call__(self, fields):
-        from grudge.tools import join_fields
-
-        #get conserved fields
-        rho=self.op.rho(fields)
-        e=self.op.e(fields)
-        rho_velocity=self.op.rho_u(fields)
-
-        #get primitive fields
-        #to do
-
-        #reset field values to cell average
-        rhoLim=self.get_average(rho)
-        eLim=self.get_average(e)
-        temp=join_fields([self.get_average(rho_vel)
-                for rho_vel in rho_velocity])
-
-        #should do for primitive fields too
-
-        return join_fields(rhoLim, eLim, temp)
-
-# }}}
-
-
-# vim: foldmethod=marker
diff --git a/grudge/models/gas_dynamics/lbm.py b/grudge/models/gas_dynamics/lbm.py
deleted file mode 100644
index 548e38aaeb0422442362c8f910b705e6c842e01d..0000000000000000000000000000000000000000
--- a/grudge/models/gas_dynamics/lbm.py
+++ /dev/null
@@ -1,204 +0,0 @@
-"""Lattice-Boltzmann operator."""
-
-__copyright__ = "Copyright (C) 2011 Andreas Kloeckner"
-
-__license__ = """
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-"""
-
-import numpy as np
-import numpy.linalg as la
-from grudge.models import HyperbolicOperator
-from pytools.obj_array import make_obj_array
-
-
-class LBMMethodBase:
-    def __len__(self):
-        return len(self.direction_vectors)
-
-    def find_opposites(self):
-        self.opposites = np.zeros(len(self))
-
-        for alpha in range(len(self)):
-            if self.opposites[alpha]:
-                continue
-
-            found = False
-            for alpha_2 in range(alpha, len(self)):
-                if la.norm(
-                        self.direction_vectors[alpha]
-                        + self.direction_vectors[alpha_2]) < 1e-12:
-                    self.opposites[alpha] = alpha_2
-                    self.opposites[alpha_2] = alpha
-                    found = True
-
-            if not found:
-                raise RuntimeError(
-                        "direction %s had no opposite"
-                        % self.direction_vectors[alpha])
-
-
-
-
-class D2Q9LBMMethod(LBMMethodBase):
-    def __init__(self):
-        self.dimensions = 2
-
-        alphas = np.arange(0, 9)
-        thetas = (alphas-1)*np.pi/2
-        thetas[5:9] += np.pi/4
-
-        direction_vectors = np.vstack([
-            np.cos(thetas), np.sin(thetas)]).T
-
-        direction_vectors[0] *= 0
-        direction_vectors[5:9] *= np.sqrt(2)
-
-        direction_vectors[np.abs(direction_vectors) < 1e-12] = 0
-
-        self.direction_vectors = direction_vectors
-
-        self.weights = np.array([4/9] + [1/9]*4 + [1/36]*4)
-
-        self.speed_of_sound = 1/np.sqrt(3)
-        self.find_opposites()
-
-    def f_equilibrium(self, rho, alpha, u):
-        e_alpha = self.direction_vectors[alpha]
-        c_s = self.speed_of_sound
-        return self.weights[alpha]*rho*(
-                1
-                + np.dot(e_alpha, u)/c_s**2
-                + 1/2*np.dot(e_alpha, u)**2/c_s**4
-                - 1/2*np.dot(u, u)/c_s**2)
-
-
-
-
-class LatticeBoltzmannOperator(HyperbolicOperator):
-    def __init__(self, method, lbm_delta_t, nu, flux_type="upwind"):
-        self.method = method
-        self.lbm_delta_t = lbm_delta_t
-        self.nu = nu
-
-        self.flux_type = flux_type
-
-    @property
-    def tau(self):
-        return (self.nu
-                /
-                (self.lbm_delta_t*self.method.speed_of_sound**2))
-
-    def get_advection_flux(self, velocity):
-        from grudge.flux import make_normal, FluxScalarPlaceholder
-        from pymbolic.primitives import IfPositive
-
-        u = FluxScalarPlaceholder(0)
-        normal = make_normal(self.method.dimensions)
-
-        if self.flux_type == "central":
-            return u.avg*np.dot(normal, velocity)
-        elif self.flux_type == "lf":
-            return u.avg*np.dot(normal, velocity) \
-                    + 0.5*la.norm(v)*(u.int - u.ext)
-        elif self.flux_type == "upwind":
-            return (np.dot(normal, velocity)*
-                    IfPositive(np.dot(normal, velocity),
-                        u.int, # outflow
-                        u.ext, # inflow
-                        ))
-        else:
-            raise ValueError("invalid flux type")
-
-    def get_advection_op(self, q, velocity):
-        from grudge.symbolic import (
-                BoundaryPair,
-                get_flux_operator,
-                make_stiffness_t,
-                InverseMassOperator)
-
-        stiff_t = make_stiffness_t(self.method.dimensions)
-
-        flux_op = get_flux_operator(self.get_advection_flux(velocity))
-        return InverseMassOperator()(
-                np.dot(velocity, stiff_t*q) - flux_op(q))
-
-    def f_bar(self):
-        from grudge.symbolic import make_sym_vector
-        return make_sym_vector("f_bar", len(self.method))
-
-    def rho(self, f_bar):
-        return sum(f_bar)
-
-    def rho_u(self, f_bar):
-        return sum(
-                dv_i * field_i
-                for dv_i, field_i in
-                zip(self.method.direction_vectors, f_bar))
-
-    def stream_rhs(self, f_bar):
-        return make_obj_array([
-            self.get_advection_op(f_bar_alpha, e_alpha)
-            for e_alpha, f_bar_alpha in
-            zip(self.method.direction_vectors, f_bar)])
-
-    def collision_update(self, f_bar):
-        from grudge.symbolic.primitives import make_common_subexpression as cse
-        rho = cse(self.rho(f_bar), "rho")
-        rho_u = self.rho_u(f_bar)
-        u = cse(rho_u/rho, "u")
-
-        f_eq_func = self.method.f_equilibrium
-        f_eq = make_obj_array([
-            f_eq_func(rho, alpha, u) for alpha in range(len(self.method))])
-
-        return f_bar - 1/(self.tau+1/2)*(f_bar - f_eq)
-
-    def bind_rhs(self, discr):
-        compiled_sym_operator = discr.compile(
-                self.stream_rhs(self.f_bar()))
-
-        #from grudge.mesh import check_bc_coverage, BTAG_ALL
-        #check_bc_coverage(discr.mesh, [BTAG_ALL])
-
-        def rhs(t, f_bar):
-            return compiled_sym_operator(f_bar=f_bar)
-
-        return rhs
-
-    def bind(self, discr, what):
-        f_bar_sym = self.f_bar()
-
-        from grudge.symbolic.mappers.type_inference import (
-                type_info, NodalRepresentation)
-
-        type_hints = {
-                f_bar_i: type_info.VolumeVector(NodalRepresentation())
-                for f_bar_i in f_bar_sym}
-
-        compiled_sym_operator = discr.compile(what(f_bar_sym), type_hints=type_hints)
-
-        def rhs(f_bar):
-            return compiled_sym_operator(f_bar=f_bar)
-
-        return rhs
-
-    def max_eigenvalue(self, t=None, fields=None, discr=None):
-        return max(
-                la.norm(v) for v in self.method.direction_vectors)
diff --git a/grudge/models/nd_calculus.py b/grudge/models/nd_calculus.py
deleted file mode 100644
index bcfbd57d758723412dc9b4c73292759bab0ea2ae..0000000000000000000000000000000000000000
--- a/grudge/models/nd_calculus.py
+++ /dev/null
@@ -1,130 +0,0 @@
-"""Canned operators for multivariable calculus."""
-
-__copyright__ = "Copyright (C) 2009 Andreas Kloeckner"
-
-__license__ = """
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-"""
-
-from grudge.models import Operator
-
-
-class GradientOperator(Operator):
-    def __init__(self, dimensions):
-        self.dimensions = dimensions
-
-    def flux(self):
-        from grudge.flux import make_normal, FluxScalarPlaceholder
-        u = FluxScalarPlaceholder()
-
-        normal = make_normal(self.dimensions)
-        return u.int*normal - u.avg*normal
-
-    def sym_operator(self):
-        from grudge.mesh import BTAG_ALL
-        from grudge.symbolic import Field, BoundaryPair, \
-                make_nabla, InverseMassOperator, get_flux_operator
-
-        u = Field("u")
-        bc = Field("bc")
-
-        nabla = make_nabla(self.dimensions)
-        flux_op = get_flux_operator(self.flux())
-
-        return nabla*u - InverseMassOperator()(
-                flux_op(u) +
-                flux_op(BoundaryPair(u, bc, BTAG_ALL)))
-
-    def bind(self, discr):
-        compiled_sym_operator = discr.compile(self.op_template())
-
-        def op(u):
-            from grudge.mesh import BTAG_ALL
-
-            return compiled_sym_operator(u=u,
-                    bc=discr.boundarize_volume_field(u, BTAG_ALL))
-
-        return op
-
-
-
-
-class DivergenceOperator(Operator):
-    def __init__(self, dimensions, subset=None):
-        self.dimensions = dimensions
-
-        if subset is None:
-            self.subset = dimensions * [True,]
-        else:
-            # chop off any extra dimensions
-            self.subset = subset[:dimensions]
-
-        from grudge.tools import count_subset
-        self.arg_count = count_subset(self.subset)
-
-    def flux(self):
-        from grudge.flux import make_normal, FluxVectorPlaceholder
-
-        v = FluxVectorPlaceholder(self.arg_count)
-
-        normal = make_normal(self.dimensions)
-
-        flux = 0
-        idx = 0
-
-        for i, i_enabled in enumerate(self.subset):
-            if i_enabled and i < self.dimensions:
-                flux += (v.int-v.avg)[idx]*normal[i]
-                idx += 1
-
-        return flux
-
-    def sym_operator(self):
-        from grudge.mesh import BTAG_ALL
-        from grudge.symbolic import make_sym_vector, BoundaryPair, \
-                get_flux_operator, make_nabla, InverseMassOperator
-
-        nabla = make_nabla(self.dimensions)
-        m_inv = InverseMassOperator()
-
-        v = make_sym_vector("v", self.arg_count)
-        bc = make_sym_vector("bc", self.arg_count)
-
-        local_op_result = 0
-        idx = 0
-        for i, i_enabled in enumerate(self.subset):
-            if i_enabled and i < self.dimensions:
-                local_op_result += nabla[i]*v[idx]
-                idx += 1
-
-        flux_op = get_flux_operator(self.flux())
-
-        return local_op_result - m_inv(
-                flux_op(v) +
-                flux_op(BoundaryPair(v, bc, BTAG_ALL)))
-
-    def bind(self, discr):
-        compiled_sym_operator = discr.compile(self.op_template())
-
-        def op(v):
-            from grudge.mesh import BTAG_ALL
-            return compiled_sym_operator(v=v,
-                    bc=discr.boundarize_volume_field(v, BTAG_ALL))
-
-        return op
diff --git a/grudge/models/pml.py b/grudge/models/pml.py
deleted file mode 100644
index bd8bfd0468ebc96ea23cbd691de0eaa3dee8344e..0000000000000000000000000000000000000000
--- a/grudge/models/pml.py
+++ /dev/null
@@ -1,279 +0,0 @@
-"""Models describing absorbing boundary layers."""
-
-__copyright__ = "Copyright (C) 2007 Andreas Kloeckner"
-
-__license__ = """
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-"""
-
-import numpy
-
-from pytools import memoize_method, Record
-from grudge.models.em import \
-        MaxwellOperator, \
-        TMMaxwellOperator, \
-        TEMaxwellOperator
-
-
-class AbarbanelGottliebPMLMaxwellOperator(MaxwellOperator):
-    """Implements a PML as in
-
-    [1] S. Abarbanel and D. Gottlieb, "On the construction and analysis of absorbing
-    layers in CEM," Applied Numerical Mathematics,  vol. 27, 1998, S. 331-340.
-    (eq 3.7-3.11)
-
-    [2] E. Turkel and A. Yefet, "Absorbing PML
-    boundary layers for wave-like equations,"
-    Applied Numerical Mathematics,  vol. 27,
-    1998, S. 533-557.
-    (eq. 4.10)
-
-    [3] Abarbanel, D. Gottlieb, and J.S. Hesthaven, "Long Time Behavior of the
-    Perfectly Matched Layer Equations in Computational Electromagnetics,"
-    Journal of Scientific Computing,  vol. 17, Dez. 2002, S. 405-422.
-
-    Generalized to 3D in doc/maxima/abarbanel-pml.mac.
-    """
-
-    class PMLCoefficients(Record):
-        __slots__ = ["sigma", "sigma_prime", "tau"]
-        # (tau=mu in [3] , to avoid confusion with permeability)
-
-        def map(self, f):
-            return self.__class__(
-                    **{name: f(getattr(self, name))
-                        for name in self.fields})
-
-    def __init__(self, *args, **kwargs):
-        self.add_decay = kwargs.pop("add_decay", True)
-        MaxwellOperator.__init__(self, *args, **kwargs)
-
-    def pml_local_op(self, w):
-        sub_e, sub_h, sub_p, sub_q = self.split_ehpq(w)
-
-        e_subset = self.get_eh_subset()[0:3]
-        h_subset = self.get_eh_subset()[3:6]
-        dim_subset = (True,) * self.dimensions + (False,) * (3-self.dimensions)
-
-        def pad_vec(v, subset):
-            result = numpy.zeros((3,), dtype=object)
-            result[numpy.array(subset, dtype=bool)] = v
-            return result
-
-        from grudge.symbolic import make_sym_vector
-        sig = pad_vec(
-                make_sym_vector("sigma", self.dimensions),
-                dim_subset)
-        sig_prime = pad_vec(
-                make_sym_vector("sigma_prime", self.dimensions),
-                dim_subset)
-        if self.add_decay:
-            tau = pad_vec(
-                    make_sym_vector("tau", self.dimensions),
-                    dim_subset)
-        else:
-            tau = numpy.zeros((3,))
-
-        e = pad_vec(sub_e, e_subset)
-        h = pad_vec(sub_h, h_subset)
-        p = pad_vec(sub_p, dim_subset)
-        q = pad_vec(sub_q, dim_subset)
-
-        rhs = numpy.zeros(12, dtype=object)
-
-        for mx in range(3):
-            my = (mx+1) % 3
-            mz = (mx+2) % 3
-
-            from grudge.tools.mathematics import levi_civita
-            assert levi_civita((mx,my,mz)) == 1
-
-            rhs[mx] += -sig[my]/self.epsilon*(2*e[mx]+p[mx]) - 2*tau[my]/self.epsilon*e[mx]
-            rhs[my] += -sig[mx]/self.epsilon*(2*e[my]+p[my]) - 2*tau[mx]/self.epsilon*e[my]
-            rhs[3+mz] += 1/(self.epsilon*self.mu) * (
-              sig_prime[mx] * q[mx] - sig_prime[my] * q[my])
-
-            rhs[6+mx] += sig[my]/self.epsilon*e[mx]
-            rhs[6+my] += sig[mx]/self.epsilon*e[my]
-            rhs[9+mx] += -sig[mx]/self.epsilon*q[mx] - (e[my] + e[mz])
-
-        from grudge.tools import full_to_subset_indices
-        sub_idx = full_to_subset_indices(e_subset+h_subset+dim_subset+dim_subset)
-
-        return rhs[sub_idx]
-
-    def sym_operator(self, w=None):
-        from grudge.tools import count_subset
-        fld_cnt = count_subset(self.get_eh_subset())
-        if w is None:
-            from grudge.symbolic import make_sym_vector
-            w = make_sym_vector("w", fld_cnt+2*self.dimensions)
-
-        from grudge.tools import join_fields
-        return join_fields(
-                MaxwellOperator.sym_operator(self, w[:fld_cnt]),
-                numpy.zeros((2*self.dimensions,), dtype=object)
-                ) + self.pml_local_op(w)
-
-    def bind(self, discr, coefficients):
-        return MaxwellOperator.bind(self, discr,
-                sigma=coefficients.sigma,
-                sigma_prime=coefficients.sigma_prime,
-                tau=coefficients.tau)
-
-    def assemble_ehpq(self, e=None, h=None, p=None, q=None, discr=None):
-        if discr is None:
-            def zero():
-                return 0
-        else:
-            def zero():
-                return discr.volume_zeros()
-
-        from grudge.tools import count_subset
-        e_components = count_subset(self.get_eh_subset()[0:3])
-        h_components = count_subset(self.get_eh_subset()[3:6])
-
-        def default_fld(fld, comp):
-            if fld is None:
-                return [zero() for i in range(comp)]
-            else:
-                return fld
-
-        e = default_fld(e, e_components)
-        h = default_fld(h, h_components)
-        p = default_fld(p, self.dimensions)
-        q = default_fld(q, self.dimensions)
-
-        from grudge.tools import join_fields
-        return join_fields(e, h, p, q)
-
-    @memoize_method
-    def partial_to_ehpq_subsets(self):
-        e_subset = self.get_eh_subset()[0:3]
-        h_subset = self.get_eh_subset()[3:6]
-
-        dim_subset = [True] * self.dimensions + [False] * (3-self.dimensions)
-
-        from grudge.tools import partial_to_all_subset_indices
-        return tuple(partial_to_all_subset_indices(
-            [e_subset, h_subset, dim_subset, dim_subset]))
-
-    def split_ehpq(self, w):
-        e_idx, h_idx, p_idx, q_idx = self.partial_to_ehpq_subsets()
-        e, h, p, q = w[e_idx], w[h_idx], w[p_idx], w[q_idx]
-
-        from grudge.flux import FluxVectorPlaceholder as FVP
-        if isinstance(w, FVP):
-            return FVP(scalars=e), FVP(scalars=h)
-        else:
-            from grudge.tools import make_obj_array as moa
-            return moa(e), moa(h), moa(p), moa(q)
-
-    # sigma business ----------------------------------------------------------
-    def _construct_scalar_coefficients(self, discr, node_coord,
-            i_min, i_max, o_min, o_max, exponent):
-        assert o_min < i_min <= i_max < o_max
-
-        if o_min != i_min:
-            l_dist = (i_min - node_coord) / (i_min-o_min)
-            l_dist_prime = discr.volume_zeros(kind="numpy", dtype=node_coord.dtype)
-            l_dist_prime[l_dist >= 0] = -1 / (i_min-o_min)
-            l_dist[l_dist < 0] = 0
-        else:
-            l_dist = l_dist_prime = numpy.zeros_like(node_coord)
-
-        if i_max != o_max:
-            r_dist = (node_coord - i_max) / (o_max-i_max)
-            r_dist_prime = discr.volume_zeros(kind="numpy", dtype=node_coord.dtype)
-            r_dist_prime[r_dist >= 0] = 1 / (o_max-i_max)
-            r_dist[r_dist < 0] = 0
-        else:
-            r_dist = r_dist_prime = numpy.zeros_like(node_coord)
-
-        l_plus_r = l_dist+r_dist
-        return l_plus_r**exponent, \
-                (l_dist_prime+r_dist_prime)*exponent*l_plus_r**(exponent-1), \
-                l_plus_r
-
-    def coefficients_from_boxes(self, discr,
-            inner_bbox, outer_bbox=None,
-            magnitude=None, tau_magnitude=None,
-            exponent=None, dtype=None):
-        if outer_bbox is None:
-            outer_bbox = discr.mesh.bounding_box()
-
-        if exponent is None:
-            exponent = 2
-
-        if magnitude is None:
-            magnitude = 20
-
-        if tau_magnitude is None:
-            tau_magnitude = 0.4
-
-        # scale by free space conductivity
-        from math import sqrt
-        magnitude = magnitude*sqrt(self.epsilon/self.mu)
-        tau_magnitude = tau_magnitude*sqrt(self.epsilon/self.mu)
-
-        i_min, i_max = inner_bbox
-        o_min, o_max = outer_bbox
-
-        from grudge.tools import make_obj_array
-
-        nodes = discr.nodes
-        if dtype is not None:
-            nodes = nodes.astype(dtype)
-
-        sigma, sigma_prime, tau = list(zip(*[self._construct_scalar_coefficients(
-            discr, nodes[:,i],
-            i_min[i], i_max[i], o_min[i], o_max[i],
-            exponent)
-            for i in range(discr.dimensions)]))
-
-        def conv(f):
-            return discr.convert_volume(f, kind=discr.compute_kind,
-                    dtype=discr.default_scalar_type)
-
-        return self.PMLCoefficients(
-                sigma=conv(magnitude*make_obj_array(sigma)),
-                sigma_prime=conv(magnitude*make_obj_array(sigma_prime)),
-                tau=conv(tau_magnitude*make_obj_array(tau)))
-
-    def coefficients_from_width(self, discr, width,
-            magnitude=None, tau_magnitude=None, exponent=None,
-            dtype=None):
-        o_min, o_max = discr.mesh.bounding_box()
-        return self.coefficients_from_boxes(discr,
-                (o_min+width, o_max-width),
-                (o_min, o_max),
-                magnitude, tau_magnitude, exponent, dtype)
-
-
-
-
-class AbarbanelGottliebPMLTEMaxwellOperator(
-        TEMaxwellOperator, AbarbanelGottliebPMLMaxwellOperator):
-    # not unimplemented--this IS the implementation.
-    pass
-
-class AbarbanelGottliebPMLTMMaxwellOperator(
-        TMMaxwellOperator, AbarbanelGottliebPMLMaxwellOperator):
-    # not unimplemented--this IS the implementation.
-    pass
diff --git a/grudge/models/wave.py b/grudge/models/wave.py
index 5fc65a70e2235ea03d33ef95ad7e91e302f313c4..ed0d40198397f070f9fba35300a0b932e99a1497 100644
--- a/grudge/models/wave.py
+++ b/grudge/models/wave.py
@@ -1,6 +1,9 @@
 """Wave equation operators."""
 
-__copyright__ = "Copyright (C) 2009 Andreas Kloeckner"
+__copyright__ = """
+Copyright (C) 2009 Andreas Kloeckner
+Copyright (C) 2021 University of Illinois Board of Trustees
+"""
 
 __license__ = """
 Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -22,12 +25,19 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 THE SOFTWARE.
 """
 
+
 import numpy as np
+
+from arraycontext.container.traversal import thaw
+
 from grudge.models import HyperbolicOperator
+
 from meshmode.mesh import BTAG_ALL, BTAG_NONE
-from grudge import sym
+
 from pytools.obj_array import flat_obj_array
 
+import grudge.op as op
+
 
 # {{{ constant-velocity
 
@@ -49,16 +59,18 @@ class WeakWaveOperator(HyperbolicOperator):
     :math:`c` is assumed to be constant across all space.
     """
 
-    def __init__(self, c, ambient_dim, source_f=0,
+    def __init__(self, dcoll, c, source_f=None,
             flux_type="upwind",
             dirichlet_tag=BTAG_ALL,
             dirichlet_bc_f=0,
             neumann_tag=BTAG_NONE,
             radiation_tag=BTAG_NONE):
-        assert isinstance(ambient_dim, int)
 
+        if source_f is None:
+            source_f = lambda actx, dcoll, t: dcoll.zeros(actx)  # noqa: E731
+
+        self.dcoll = dcoll
         self.c = c
-        self.ambient_dim = ambient_dim
         self.source_f = source_f
 
         if self.c > 0:
@@ -74,10 +86,11 @@ class WeakWaveOperator(HyperbolicOperator):
 
         self.flux_type = flux_type
 
-    def flux(self, w):
-        u = w[0]
-        v = w[1:]
-        normal = sym.normal(w.dd, self.ambient_dim)
+    def flux(self, wtpair):
+        u = wtpair[0]
+        v = wtpair[1:]
+        actx = u.int.array_context
+        normal = thaw(op.normal(self.dcoll, wtpair.dd), actx)
 
         central_flux_weak = -self.c*flat_obj_array(
                 np.dot(v.avg, normal),
@@ -92,65 +105,66 @@ class WeakWaveOperator(HyperbolicOperator):
         else:
             raise ValueError("invalid flux type '%s'" % self.flux_type)
 
-    def sym_operator(self):
-        d = self.ambient_dim
-
-        w = sym.make_sym_array("w", d+1)
+    def operator(self, t, w):
+        dcoll = self.dcoll
         u = w[0]
         v = w[1:]
+        actx = u.array_context
 
         # 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))
+        dir_u = op.project(dcoll, "vol", self.dirichlet_tag, u)
+        dir_v = op.project(dcoll, "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.var("dir_bc_u")
+            dir_g = self.dirichlet_bc_f
             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")
+        neu_u = op.project(dcoll, "vol", self.neumann_tag, u)
+        neu_v = op.project(dcoll, "vol", self.neumann_tag, v)
+        neu_bc = flat_obj_array(neu_u, -neu_v)
 
         # radiation BCs -------------------------------------------------------
-        rad_normal = sym.normal(self.radiation_tag, d)
+        rad_normal = thaw(op.normal(dcoll, dd=self.radiation_tag), actx)
 
-        rad_u = sym.cse(sym.project("vol", self.radiation_tag)(u))
-        rad_v = sym.cse(sym.project("vol", self.radiation_tag)(v))
+        rad_u = op.project(dcoll, "vol", self.radiation_tag, u)
+        rad_v = op.project(dcoll, "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")
+        rad_bc = 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)
+        )
 
         # entire operator -----------------------------------------------------
-        def flux(pair):
-            return sym.project(pair.dd, "all_faces")(self.flux(pair))
+        def flux(tpair):
+            return op.project(dcoll, tpair.dd, "all_faces", self.flux(tpair))
 
-        result = sym.InverseMassOperator()(
+        result = (
+            op.inverse_mass(
+                dcoll,
                 flat_obj_array(
-                    -self.c*np.dot(sym.stiffness_t(self.ambient_dim), v),
-                    -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))
-                    + flux(sym.bv_tpair(self.radiation_tag, w, rad_bc))
-
-                    ))
-
-        result[0] += self.source_f
+                    -self.c*op.weak_local_div(dcoll, v),
+                    -self.c*op.weak_local_grad(dcoll, u)
+                )
+                - op.face_mass(
+                    dcoll,
+                    sum(flux(tpair) for tpair in op.interior_trace_pairs(dcoll, w))
+                    + flux(op.bv_trace_pair(dcoll, self.dirichlet_tag, w, dir_bc))
+                    + flux(op.bv_trace_pair(dcoll, self.neumann_tag, w, neu_bc))
+                    + flux(op.bv_trace_pair(dcoll, self.radiation_tag, w, rad_bc))
+                )
+            )
+        )
+
+        result[0] += self.source_f(actx, dcoll, t)
 
         return result
 
@@ -188,21 +202,23 @@ class VariableCoefficientWeakWaveOperator(HyperbolicOperator):
     :math:`c` is assumed to be constant across all space.
     """
 
-    def __init__(self, c, ambient_dim, source_f=0,
+    def __init__(self, dcoll, c, source_f=None,
             flux_type="upwind",
             dirichlet_tag=BTAG_ALL,
             dirichlet_bc_f=0,
             neumann_tag=BTAG_NONE,
             radiation_tag=BTAG_NONE):
-        assert isinstance(ambient_dim, int)
 
+        if source_f is None:
+            source_f = lambda actx, dcoll, t: dcoll.zeros(actx)  # noqa: E731
+
+        self.dcoll = dcoll
         self.c = c
-        self.ambient_dim = ambient_dim
         self.source_f = source_f
 
-        self.sign = sym.If(sym.Comparison(
-                            self.c, ">", 0),
-                                            np.int32(1), np.int32(-1))
+        actx = dcoll._setup_actx
+        ones = dcoll.zeros(actx) + 1
+        self.sign = actx.np.where(self.c > 0, ones, -ones)
 
         self.dirichlet_tag = dirichlet_tag
         self.neumann_tag = neumann_tag
@@ -212,11 +228,12 @@ class VariableCoefficientWeakWaveOperator(HyperbolicOperator):
 
         self.flux_type = flux_type
 
-    def flux(self, w):
-        c = w[0]
-        u = w[1]
-        v = w[2:]
-        normal = sym.normal(w.dd, self.ambient_dim)
+    def flux(self, wtpair):
+        c = wtpair[0]
+        u = wtpair[1]
+        v = wtpair[2:]
+        actx = u.int.array_context
+        normal = thaw(op.normal(self.dcoll, wtpair.dd), actx)
 
         flux_central_weak = -0.5 * flat_obj_array(
             np.dot(v.int*c.int + v.ext*c.ext, normal),
@@ -234,71 +251,76 @@ class VariableCoefficientWeakWaveOperator(HyperbolicOperator):
         else:
             raise ValueError("invalid flux type '%s'" % self.flux_type)
 
-    def sym_operator(self):
-        d = self.ambient_dim
-
-        w = sym.make_sym_array("w", d+1)
+    def operator(self, t, w):
+        dcoll = self.dcoll
         u = w[0]
         v = w[1:]
         flux_w = flat_obj_array(self.c, w)
+        actx = u.array_context
 
         # boundary conditions -------------------------------------------------
 
         # dirichlet BCs -------------------------------------------------------
-        dir_c = sym.cse(sym.project("vol", self.dirichlet_tag)(self.c))
-        dir_u = sym.cse(sym.project("vol", self.dirichlet_tag)(u))
-        dir_v = sym.cse(sym.project("vol", self.dirichlet_tag)(v))
+        dir_c = op.project(dcoll, "vol", self.dirichlet_tag, self.c)
+        dir_u = op.project(dcoll, "vol", self.dirichlet_tag, u)
+        dir_v = op.project(dcoll, "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.var("dir_bc_u")
+            dir_g = self.dirichlet_bc_f
             dir_bc = flat_obj_array(dir_c, 2*dir_g - dir_u, dir_v)
         else:
             dir_bc = flat_obj_array(dir_c, -dir_u, dir_v)
 
-        dir_bc = sym.cse(dir_bc, "dir_bc")
-
         # neumann BCs ---------------------------------------------------------
-        neu_c = sym.cse(sym.project("vol", self.neumann_tag)(self.c))
-        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_c, neu_u, -neu_v), "neu_bc")
+        neu_c = op.project(dcoll, "vol", self.neumann_tag, self.c)
+        neu_u = op.project(dcoll, "vol", self.neumann_tag, u)
+        neu_v = op.project(dcoll, "vol", self.neumann_tag, v)
+        neu_bc = flat_obj_array(neu_c, neu_u, -neu_v)
 
         # radiation BCs -------------------------------------------------------
-        rad_normal = sym.normal(self.radiation_tag, d)
+        rad_normal = thaw(op.normal(dcoll, dd=self.radiation_tag), actx)
 
-        rad_c = sym.cse(sym.project("vol", self.radiation_tag)(self.c))
-        rad_u = sym.cse(sym.project("vol", self.radiation_tag)(u))
-        rad_v = sym.cse(sym.project("vol", self.radiation_tag)(v))
+        rad_c = op.project(dcoll, "vol", self.radiation_tag, self.c)
+        rad_u = op.project(dcoll, "vol", self.radiation_tag, u)
+        rad_v = op.project(dcoll, "vol", self.radiation_tag, v)
+        rad_sign = op.project(dcoll, "vol", self.radiation_tag, self.sign)
 
-        rad_bc = sym.cse(flat_obj_array(rad_c,
-                0.5*(rad_u - sym.project("vol", self.radiation_tag)(self.sign)
-                    * np.dot(rad_normal, rad_v)),
-                0.5*rad_normal*(np.dot(rad_normal, rad_v)
-                    - sym.project("vol", self.radiation_tag)(self.sign)*rad_u)
-                ), "rad_bc")
+        rad_bc = flat_obj_array(
+            rad_c,
+            0.5*(rad_u - rad_sign * np.dot(rad_normal, rad_v)),
+            0.5*rad_normal*(np.dot(rad_normal, rad_v) - rad_sign*rad_u)
+        )
 
         # entire operator -----------------------------------------------------
-        def flux(pair):
-            return sym.project(pair.dd, "all_faces")(self.flux(pair))
+        def flux(tpair):
+            return op.project(dcoll, tpair.dd, "all_faces", self.flux(tpair))
 
-        result = sym.InverseMassOperator()(
+        result = (
+            op.inverse_mass(
+                dcoll,
                 flat_obj_array(
-                    -self.c*np.dot(sym.stiffness_t(self.ambient_dim), v),
-                    -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))
-                    + flux(sym.bv_tpair(self.radiation_tag, flux_w, rad_bc))
-
-                    ))
-
-        result[0] += self.source_f
+                    -self.c*op.weak_local_div(dcoll, v),
+                    -self.c*op.weak_local_grad(dcoll, u)
+                )
+                - op.face_mass(
+                    dcoll,
+                    sum(flux(tpair)
+                        for tpair in op.interior_trace_pairs(dcoll, flux_w))
+                    + flux(op.bv_trace_pair(dcoll, self.dirichlet_tag,
+                                            flux_w, dir_bc))
+                    + flux(op.bv_trace_pair(dcoll, self.neumann_tag,
+                                            flux_w, neu_bc))
+                    + flux(op.bv_trace_pair(dcoll, self.radiation_tag,
+                                            flux_w, rad_bc))
+                )
+            )
+        )
+
+        result[0] += self.source_f(actx, dcoll, t)
 
         return result
 
@@ -310,7 +332,8 @@ class VariableCoefficientWeakWaveOperator(HyperbolicOperator):
             self.radiation_tag])
 
     def max_eigenvalue(self, t, fields=None, discr=None):
-        return sym.NodalMax("vol")(sym.fabs(self.c))
+        actx = self.dcoll._setup_actx
+        return op.nodal_max(self.dcoll, "vol", actx.np.fabs(self.c))
 
 # }}}
 
diff --git a/grudge/op.py b/grudge/op.py
index 6efde43f70f18bf1004e357265fc38c86cf8d64e..3929503f323d51a55f054a982e05ae7265d417f0 100644
--- a/grudge/op.py
+++ b/grudge/op.py
@@ -1,30 +1,66 @@
 """
+Data transfer and geometry
+^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Projection and interpolation
+----------------------------
+
 .. autofunction:: project
+
+Geometric quantities
+--------------------
+
 .. autofunction:: nodes
+.. autofunction:: normal
+.. autofunction:: h_max_from_volume
+.. autofunction:: h_min_from_volume
+
+Core DG routines
+^^^^^^^^^^^^^^^^
+
+Elementwise differentiation
+---------------------------
 
 .. autofunction:: local_grad
 .. autofunction:: local_d_dx
 .. autofunction:: local_div
 
+Weak derivative operators
+-------------------------
+
 .. autofunction:: weak_local_grad
 .. autofunction:: weak_local_d_dx
 .. autofunction:: weak_local_div
 
-.. autofunction:: normal
+Mass, inverse mass, and face mass operators
+-------------------------------------------
+
 .. autofunction:: mass
 .. autofunction:: inverse_mass
 .. autofunction:: face_mass
 
+Support functions
+^^^^^^^^^^^^^^^^^
+
+Nodal reductions
+----------------
+
 .. autofunction:: norm
 .. autofunction:: nodal_sum
 .. autofunction:: nodal_min
 .. autofunction:: nodal_max
+.. autofunction:: integral
+
+Elementwise reductions
+----------------------
 
-.. autofunction:: interior_trace_pair
-.. autofunction:: cross_rank_trace_pairs
+.. autofunction:: elementwise_sum
 """
 
-__copyright__ = "Copyright (C) 2021 Andreas Kloeckner"
+__copyright__ = """
+Copyright (C) 2021 Andreas Kloeckner
+Copyright (C) 2021 University of Illinois Board of Trustees
+"""
 
 __license__ = """
 Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -48,37 +84,74 @@ THE SOFTWARE.
 
 
 from numbers import Number
-from pytools import memoize_on_first_arg
-
-import numpy as np  # noqa
+from functools import reduce
+
+from arraycontext import (
+    ArrayContext,
+    FirstAxisIsElementsTag,
+    make_loopy_program,
+    freeze
+)
+
+from grudge.discretization import DiscretizationCollection
+
+from pytools import (
+    memoize_in,
+    memoize_on_first_arg,
+    keyed_memoize_in
+)
 from pytools.obj_array import obj_array_vectorize, make_obj_array
-import pyopencl.array as cla  # noqa
-from grudge import sym, bind
 
+from meshmode.dof_array import DOFArray
+
+import numpy as np
 import grudge.dof_desc as dof_desc
 
-from meshmode.mesh import BTAG_ALL, BTAG_NONE, BTAG_PARTITION  # noqa
-from meshmode.dof_array import freeze, flatten, unflatten
+from grudge.trace_pair import (  # noqa
+    interior_trace_pair,
+    interior_trace_pairs,
+    connected_ranks,
+    cross_rank_trace_pairs,
+    bdry_trace_pair,
+    bv_trace_pair
+)
+
 
-from grudge.symbolic.primitives import TracePair
+# {{{ Kernel tags
 
+class HasElementwiseMatvecTag(FirstAxisIsElementsTag):
+    """A tag that is applicable to kernel programs indicating that
+    an element-wise matrix product is being performed. This indicates
+    that the first index corresponds to element indices and suggests that
+    the implementation should set element indices as the outermost
+    loop extent.
+    """
+
+# }}}
 
-# def interp(dcoll, src, tgt, vec):
+
+# {{{ Interpolation and projection
+
+# FIXME: Should reintroduce interp and make clear distinctions
+# between projection and interpolations.
+# Related issue: https://github.com/inducer/grudge/issues/38
+# def interp(dcoll: DiscretizationCollection, src, tgt, vec):
 #     from warnings import warn
 #     warn("using 'interp' is deprecated, use 'project' instead.",
-#             DeprecationWarning, stacklevel=2)
+#          DeprecationWarning, stacklevel=2)
 #
-#     return dcoll.project(src, tgt, vec)
+#     return project(dcoll, src, tgt, vec)
 
 
-def project(dcoll, src, tgt, vec):
+def project(dcoll: DiscretizationCollection, 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.dof_desc.DOFDesc`, or a value convertible to one
-    :arg tgt: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one
-    :arg vec: a :class:`~meshmode.dof_array.DOFArray`
+    :arg src: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+    :arg tgt: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+    :arg vec: a :class:`~meshmode.dof_array.DOFArray` or a
+        :class:`~arraycontext.ArrayContainer`.
     """
     src = dof_desc.as_dofdesc(src)
     tgt = dof_desc.as_dofdesc(tgt)
@@ -94,57 +167,155 @@ def project(dcoll, src, tgt, vec):
 
     return dcoll.connection_from_dds(src, tgt)(vec)
 
+# }}}
+
 
 # {{{ geometric properties
 
-def nodes(dcoll, dd=None):
+def nodes(dcoll: DiscretizationCollection, dd=None) -> np.ndarray:
     r"""Return the nodes of a discretization.
 
     :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
         Defaults to the base volume discretization.
-    :returns: an object array of :class:`~meshmode.dof_array.DOFArray`\ s
+    :returns: an object array of :class:`~meshmode.dof_array.DOFArray`\ s.
     """
     if dd is None:
-        return dcoll._volume_discr.nodes()
-    else:
-        return dcoll.discr_from_dd(dd).nodes()
+        dd = dof_desc.DD_VOLUME
+    dd = dof_desc.as_dofdesc(dd)
+
+    return dcoll.discr_from_dd(dd).nodes()
 
 
 @memoize_on_first_arg
-def normal(dcoll, dd):
-    """Get unit normal to specified surface discretization, *dd*.
+def normal(dcoll: DiscretizationCollection, dd) -> np.ndarray:
+    r"""Get the unit normal to the specified surface discretization, *dd*.
 
     :arg dd: a :class:`~grudge.dof_desc.DOFDesc` as the surface discretization.
-    :returns: an object array of :class:`~meshmode.dof_array.DOFArray`.
+    :returns: an object array of :class:`~meshmode.dof_array.DOFArray`\ s.
     """
-    surface_discr = dcoll.discr_from_dd(dd)
-    actx = surface_discr._setup_actx
-    return freeze(
-            bind(dcoll,
-                sym.normal(dd, surface_discr.ambient_dim, surface_discr.dim),
-                local_only=True)
-            (array_context=actx))
+    from grudge.geometry import normal
 
-# }}}
+    actx = dcoll.discr_from_dd(dd)._setup_actx
+    return freeze(normal(actx, dcoll, dd))
 
 
-# {{{ derivatives
+@memoize_on_first_arg
+def h_max_from_volume(dcoll: DiscretizationCollection, dim=None, dd=None):
+    """Returns a (maximum) characteristic length based on the volume of the
+    elements. This length may not be representative if the elements have very
+    high aspect ratios.
+
+    :arg dim: an integer denoting topological dimension. If *None*, the
+        spatial dimension specified by
+        :attr:`grudge.DiscretizationCollection.dim` is used.
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+        Defaults to the base volume discretization if not provided.
+    :returns: a scalar denoting the maximum characteristic length.
+    """
+    if dd is None:
+        dd = dof_desc.DD_VOLUME
+    dd = dof_desc.as_dofdesc(dd)
+
+    if dim is None:
+        dim = dcoll.dim
+
+    ones = dcoll.discr_from_dd(dd).zeros(dcoll._setup_actx) + 1.0
+    return nodal_max(
+        dcoll,
+        dd,
+        elementwise_sum(dcoll, mass(dcoll, dd, ones))
+    ) ** (1.0 / dim)
+
 
 @memoize_on_first_arg
-def _bound_grad(dcoll):
-    return bind(dcoll, sym.nabla(dcoll.dim) * sym.Variable("u"), local_only=True)
+def h_min_from_volume(dcoll: DiscretizationCollection, dim=None, dd=None):
+    """Returns a (minimum) characteristic length based on the volume of the
+    elements. This length may not be representative if the elements have very
+    high aspect ratios.
+
+    :arg dim: an integer denoting topological dimension. If *None*, the
+        spatial dimension specified by
+        :attr:`grudge.DiscretizationCollection.dim` is used.
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+        Defaults to the base volume discretization if not provided.
+    :returns: a scalar denoting the minimum characteristic length.
+    """
+    if dd is None:
+        dd = dof_desc.DD_VOLUME
+    dd = dof_desc.as_dofdesc(dd)
+
+    if dim is None:
+        dim = dcoll.dim
+
+    ones = dcoll.discr_from_dd(dd).zeros(dcoll._setup_actx) + 1.0
+    return nodal_min(
+        dcoll,
+        dd,
+        elementwise_sum(dcoll, mass(dcoll, dd, ones))
+    ) ** (1.0 / dim)
+
+# }}}
+
+
+# {{{ Derivative operators
+
+def reference_derivative_matrices(actx: ArrayContext, element_group):
+    @keyed_memoize_in(
+        actx, reference_derivative_matrices,
+        lambda grp: grp.discretization_key())
+    def get_ref_derivative_mats(grp):
+        from meshmode.discretization.poly_element import diff_matrices
+        return actx.freeze(
+            actx.from_numpy(
+                np.asarray(
+                    [dfmat for dfmat in diff_matrices(grp)]
+                )
+            )
+        )
+    return get_ref_derivative_mats(element_group)
 
 
-def local_grad(dcoll, vec, *, nested=False):
-    r"""Return the element-local gradient of the volume function represented by
-    *vec*.
+def _compute_local_gradient(dcoll: DiscretizationCollection, vec, xyz_axis):
+    from grudge.geometry import inverse_surface_metric_derivative
+
+    discr = dcoll.discr_from_dd(dof_desc.DD_VOLUME)
+    actx = vec.array_context
+
+    inverse_jac_t = actx.np.stack(
+        [inverse_surface_metric_derivative(actx, dcoll, rst_axis, xyz_axis)
+         for rst_axis in range(dcoll.dim)]
+    )
+    return DOFArray(
+        actx,
+        data=tuple(
+            actx.einsum("dei,dij,ej->ei",
+                        inv_jac_t_i,
+                        reference_derivative_matrices(actx, grp),
+                        vec_i,
+                        arg_names=("inv_jac_t", "ref_diff_mat", "vec"),
+                        tagged=(HasElementwiseMatvecTag(),))
+
+            for grp, vec_i, inv_jac_t_i in zip(discr.groups, vec, inverse_jac_t)
+        )
+    )
+
+
+def local_grad(
+        dcoll: DiscretizationCollection, vec, *, nested=False) -> np.ndarray:
+    r"""Return the element-local gradient of a function :math:`f` represented
+    by *vec*:
+
+    .. math::
+
+        \nabla|_E f = \left(
+            \partial_x|_E f, \partial_y|_E f, \partial_z|_E f \right)
 
     :arg vec: a :class:`~meshmode.dof_array.DOFArray` or object array of
-        `~meshmode.dof_array.DOFArray`
+        :class:`~meshmode.dof_array.DOFArray`\ s.
     :arg nested: return nested object arrays instead of a single multidimensional
-        array if *vec* is non-scalar
+        array if *vec* is non-scalar.
     :returns: an object array (possibly nested) of
-        :class:`~meshmode.dof_array.DOFArray`\ s
+        :class:`~meshmode.dof_array.DOFArray`\ s.
     """
     if isinstance(vec, np.ndarray):
         grad = obj_array_vectorize(
@@ -154,28 +325,27 @@ def local_grad(dcoll, vec, *, nested=False):
         else:
             return np.stack(grad, axis=0)
 
-    return _bound_grad(dcoll)(u=vec)
+    return make_obj_array([_compute_local_gradient(dcoll, vec, xyz_axis)
+                           for xyz_axis in range(dcoll.dim)])
 
 
-@memoize_on_first_arg
-def _bound_d_dx(dcoll, xyz_axis):
-    return bind(dcoll, sym.nabla(dcoll.dim)[xyz_axis] * sym.Variable("u"),
-            local_only=True)
+def local_d_dx(dcoll: DiscretizationCollection, xyz_axis, vec):
+    r"""Return the element-local derivative along axis *xyz_axis* of a
+    function :math:`f` represented by *vec*:
 
+    .. math::
 
-def local_d_dx(dcoll, xyz_axis, vec):
-    r"""Return the element-local derivative along axis *xyz_axis* of the volume
-    function represented by *vec*.
+        \frac{\partial f}{\partial \lbrace x,y,z\rbrace}\Big|_E
 
     :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
+        is taken.
+    :arg vec: a :class:`~meshmode.dof_array.DOFArray`.
+    :returns: a :class:`~meshmode.dof_array.DOFArray`\ s.
     """
-    return _bound_d_dx(dcoll, xyz_axis)(u=vec)
+    return _compute_local_gradient(dcoll, vec, xyz_axis)
 
 
-def _div_helper(dcoll, diff_func, vecs):
+def _div_helper(dcoll: DiscretizationCollection, diff_func, vecs):
     if not isinstance(vecs, np.ndarray):
         raise TypeError("argument must be an object array")
     assert vecs.dtype == object
@@ -198,43 +368,134 @@ def _div_helper(dcoll, diff_func, vecs):
         return result
 
 
-def local_div(dcoll, vecs):
-    r"""Return the element-local divergence of the vector volume function
-    represented by *vecs*.
+def local_div(dcoll: DiscretizationCollection, vecs):
+    r"""Return the element-local divergence of the vector function
+    :math:`\mathbf{f}` represented by *vecs*:
+
+    .. math::
+
+        \nabla|_E \cdot \mathbf{f} = \sum_{i=1}^d \partial_{x_i}|_E \mathbf{f}_i
 
     :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`
+    :returns: a :class:`~meshmode.dof_array.DOFArray`.
     """
 
     return _div_helper(dcoll,
             lambda i, subvec: local_d_dx(dcoll, i, subvec),
             vecs)
 
-
-@memoize_on_first_arg
-def _bound_weak_grad(dcoll, dd):
-    return bind(dcoll,
-            sym.stiffness_t(dcoll.dim, dd_in=dd) * sym.Variable("u", dd),
-            local_only=True)
+# }}}
 
 
-def weak_local_grad(dcoll, *args, nested=False):
+# {{{ Weak derivative operators
+
+def reference_stiffness_transpose_matrix(
+        actx: ArrayContext, out_element_group, in_element_group):
+    @keyed_memoize_in(
+        actx, reference_stiffness_transpose_matrix,
+        lambda out_grp, in_grp: (out_grp.discretization_key(),
+                                 in_grp.discretization_key()))
+    def get_ref_stiffness_transpose_mat(out_grp, in_grp):
+        if in_grp == out_grp:
+            from meshmode.discretization.poly_element import \
+                mass_matrix, diff_matrices
+
+            mmat = mass_matrix(out_grp)
+            return actx.freeze(
+                actx.from_numpy(
+                    np.asarray(
+                        [dmat.T @ mmat.T for dmat in diff_matrices(out_grp)]
+                    )
+                )
+            )
+
+        from modepy import vandermonde
+        basis = out_grp.basis_obj()
+        vand = vandermonde(basis.functions, out_grp.unit_nodes)
+        grad_vand = vandermonde(basis.gradients, in_grp.unit_nodes)
+        vand_inv_t = np.linalg.inv(vand).T
+
+        if not isinstance(grad_vand, tuple):
+            # NOTE: special case for 1d
+            grad_vand = (grad_vand,)
+
+        weights = in_grp.quadrature_rule().weights
+        return actx.freeze(
+            actx.from_numpy(
+                np.einsum(
+                    "c,bz,acz->abc",
+                    weights,
+                    vand_inv_t,
+                    grad_vand
+                ).copy()  # contigify the array
+            )
+        )
+    return get_ref_stiffness_transpose_mat(out_element_group,
+                                           in_element_group)
+
+
+def _apply_stiffness_transpose_operator(
+        dcoll: DiscretizationCollection, dd_out, dd_in, vec, xyz_axis):
+    from grudge.geometry import \
+        inverse_surface_metric_derivative, area_element
+
+    in_discr = dcoll.discr_from_dd(dd_in)
+    out_discr = dcoll.discr_from_dd(dd_out)
+
+    actx = vec.array_context
+    area_elements = area_element(actx, dcoll, dd=dd_in)
+    inverse_jac_t = actx.np.stack(
+        [inverse_surface_metric_derivative(actx, dcoll,
+                                           rst_axis, xyz_axis, dd=dd_in)
+         for rst_axis in range(dcoll.dim)]
+    )
+    return DOFArray(
+        actx,
+        data=tuple(
+            actx.einsum("dij,ej,ej,dej->ei",
+                        reference_stiffness_transpose_matrix(
+                            actx,
+                            out_element_group=out_grp,
+                            in_element_group=in_grp
+                        ),
+                        ae_i,
+                        vec_i,
+                        inv_jac_t_i,
+                        arg_names=("ref_stiffT_mat", "jac", "vec", "inv_jac_t"),
+                        tagged=(HasElementwiseMatvecTag(),))
+
+            for out_grp, in_grp, vec_i, ae_i, inv_jac_t_i in zip(out_discr.groups,
+                                                                 in_discr.groups,
+                                                                 vec,
+                                                                 area_elements,
+                                                                 inverse_jac_t)
+        )
+    )
+
+
+def weak_local_grad(dcoll: DiscretizationCollection, *args, nested=False):
     r"""Return the element-local weak gradient of the volume function
     represented by *vec*.
 
     May be called with ``(vecs)`` or ``(dd, vecs)``.
 
+    Specifically, the function returns an object array where the :math:`i`-th
+    component is the weak derivative with respect to the :math:`i`-th coordinate
+    of a scalar function :math:`f`. See :func:`weak_local_d_dx` for further
+    information. For non-scalar :math:`f`, the function will return a nested object
+    array containing the component-wise weak derivatives.
+
     :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
         Defaults to the base volume discretization if not provided.
     :arg vec: a :class:`~meshmode.dof_array.DOFArray` or object array of
-        `~meshmode.dof_array.DOFArray`
+        :class:`~meshmode.dof_array.DOFArray`\ s.
     :arg nested: return nested object arrays instead of a single multidimensional
         array if *vec* is non-scalar
     :returns: an object array (possibly nested) of
-        :class:`~meshmode.dof_array.DOFArray`\ s
+        :class:`~meshmode.dof_array.DOFArray`\ s.
     """
     if len(args) == 1:
         vec, = args
@@ -252,27 +513,39 @@ def weak_local_grad(dcoll, *args, nested=False):
         else:
             return np.stack(grad, axis=0)
 
-    return _bound_weak_grad(dcoll, dd)(u=vec)
+    return make_obj_array(
+        [_apply_stiffness_transpose_operator(dcoll,
+                                             dof_desc.DD_VOLUME,
+                                             dd, vec, xyz_axis)
+         for xyz_axis in range(dcoll.dim)]
+    )
 
 
-@memoize_on_first_arg
-def _bound_weak_d_dx(dcoll, dd, xyz_axis):
-    return bind(dcoll,
-            sym.stiffness_t(dcoll.dim, dd_in=dd)[xyz_axis]
-            * sym.Variable("u", dd),
-            local_only=True)
-
-
-def weak_local_d_dx(dcoll, *args):
+def weak_local_d_dx(dcoll: DiscretizationCollection, *args):
     r"""Return the element-local weak derivative along axis *xyz_axis* of the
     volume function represented by *vec*.
 
     May be called with ``(xyz_axis, vecs)`` or ``(dd, xyz_axis, vecs)``.
 
+    Specifically, this function computes the volume contribution of the
+    weak derivative in the :math:`i`-th component (specified by *xyz_axis*)
+    of a function :math:`f`, in each element :math:`E`, with respect to polynomial
+    test functions :math:`\phi`:
+
+    .. math::
+
+        \int_E \partial_i\phi\,f\,\mathrm{d}x \sim
+        \mathbf{D}_{E,i}^T \mathbf{M}_{E}^T\mathbf{f}|_E,
+
+    where :math:`\mathbf{D}_{E,i}` is the polynomial differentiation matrix on
+    an :math:`E` for the :math:`i`-th spatial coordinate, :math:`\mathbf{M}_E`
+    is the elemental mass matrix (see :func:`mass` for more information), and
+    :math:`\mathbf{f}|_E` is a vector of coefficients for :math:`f` on :math:`E`.
+
     :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
+    :arg vec: a :class:`~meshmode.dof_array.DOFArray`.
+    :returns: a :class:`~meshmode.dof_array.DOFArray`\ s.
     """
     if len(args) == 2:
         xyz_axis, vec = args
@@ -282,22 +555,37 @@ def weak_local_d_dx(dcoll, *args):
     else:
         raise TypeError("invalid number of arguments")
 
-    return _bound_weak_d_dx(dcoll, dd, xyz_axis)(u=vec)
+    return _apply_stiffness_transpose_operator(dcoll,
+                                               dof_desc.DD_VOLUME,
+                                               dd, vec, xyz_axis)
 
 
-def weak_local_div(dcoll, *args):
+def weak_local_div(dcoll: DiscretizationCollection, *args):
     r"""Return the element-local weak divergence of the vector volume function
     represented by *vecs*.
 
     May be called with ``(vecs)`` or ``(dd, vecs)``.
 
+    Specifically, this function computes the volume contribution of the
+    weak divergence of a vector function :math:`\mathbf{f}`, in each element
+    :math:`E`, with respect to polynomial test functions :math:`\phi`:
+
+    .. math::
+
+        \int_E \nabla \phi \cdot \mathbf{f}\,\mathrm{d}x \sim
+        \sum_{i=1}^d \mathbf{D}_{E,i}^T \mathbf{M}_{E}^T\mathbf{f}_i|_E,
+
+    where :math:`\mathbf{D}_{E,i}` is the polynomial differentiation matrix on
+    an :math:`E` for the :math:`i`-th spatial coordinate, and :math:`\mathbf{M}_E`
+    is the elemental mass matrix (see :func:`mass` for more information).
+
     :arg dd: a :class:`~grudge.dof_desc.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`
+    :returns: a :class:`~meshmode.dof_array.DOFArray`.
     """
     if len(args) == 1:
         vecs, = args
@@ -314,15 +602,108 @@ def weak_local_div(dcoll, *args):
 # }}}
 
 
-# {{{ mass-like
+# {{{ Mass operator
 
-@memoize_on_first_arg
-def _bound_mass(dcoll, dd):
-    return bind(dcoll, sym.MassOperator(dd_in=dd)(sym.Variable("u", dd)),
-            local_only=True)
+def reference_mass_matrix(actx: ArrayContext, out_element_group, in_element_group):
+    @keyed_memoize_in(
+        actx, reference_mass_matrix,
+        lambda out_grp, in_grp: (out_grp.discretization_key(),
+                                 in_grp.discretization_key()))
+    def get_ref_mass_mat(out_grp, in_grp):
+        if out_grp == in_grp:
+            from meshmode.discretization.poly_element import mass_matrix
+
+            return actx.freeze(
+                actx.from_numpy(
+                    np.asarray(
+                        mass_matrix(out_grp),
+                        order="C"
+                    )
+                )
+            )
+
+        from modepy import vandermonde
+        basis = out_grp.basis_obj()
+        vand = vandermonde(basis.functions, out_grp.unit_nodes)
+        o_vand = vandermonde(basis.functions, in_grp.unit_nodes)
+        vand_inv_t = np.linalg.inv(vand).T
+
+        weights = in_grp.quadrature_rule().weights
+        return actx.freeze(
+            actx.from_numpy(
+                np.asarray(
+                    np.einsum("j,ik,jk->ij", weights, vand_inv_t, o_vand),
+                    order="C"
+                )
+            )
+        )
+
+    return get_ref_mass_mat(out_element_group, in_element_group)
+
+
+def _apply_mass_operator(
+        dcoll: DiscretizationCollection, dd_out, dd_in, vec):
+    if isinstance(vec, np.ndarray):
+        return obj_array_vectorize(
+            lambda vi: _apply_mass_operator(dcoll,
+                                            dd_out,
+                                            dd_in, vi), vec
+        )
+
+    from grudge.geometry import area_element
+
+    in_discr = dcoll.discr_from_dd(dd_in)
+    out_discr = dcoll.discr_from_dd(dd_out)
+
+    actx = vec.array_context
+    area_elements = area_element(actx, dcoll, dd=dd_in)
+    return DOFArray(
+        actx,
+        data=tuple(
+            actx.einsum("ij,ej,ej->ei",
+                        reference_mass_matrix(
+                            actx,
+                            out_element_group=out_grp,
+                            in_element_group=in_grp
+                        ),
+                        ae_i,
+                        vec_i,
+                        arg_names=("mass_mat", "jac", "vec"),
+                        tagged=(HasElementwiseMatvecTag(),))
+
+            for in_grp, out_grp, ae_i, vec_i in zip(
+                    in_discr.groups, out_discr.groups, area_elements, vec)
+        )
+    )
+
+
+def mass(dcoll: DiscretizationCollection, *args):
+    r"""Return the action of the DG mass matrix on a vector (or vectors)
+    of :class:`~meshmode.dof_array.DOFArray`\ s, *vec*. In the case of
+    *vec* being an object array of :class:`~meshmode.dof_array.DOFArray`\ s,
+    the mass operator is applied in the Kronecker sense (component-wise).
+
+    May be called with ``(vec)`` or ``(dd, vec)``.
+
+    Specifically, this function applies the mass matrix elementwise on a
+    vector of coefficients :math:`\mathbf{f}` via:
+    :math:`\mathbf{M}_{E}\mathbf{f}|_E`, where
+
+    .. math::
+
+        \left(\mathbf{M}_{E}\right)_{ij} = \int_E \phi_i \cdot \phi_j\,\mathrm{d}x,
 
+    where :math:`\phi_i` are local polynomial basis functions on :math:`E`.
+
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+        Defaults to the base volume discretization if not provided.
+    :arg vec: a :class:`~meshmode.dof_array.DOFArray` or object array of
+        :class:`~meshmode.dof_array.DOFArray`\ s.
+    :returns: a :class:`~meshmode.dof_array.DOFArray` denoting the
+        application of the mass matrix, or an object array of
+        :class:`~meshmode.dof_array.DOFArray`\ s.
+    """
 
-def mass(dcoll, *args):
     if len(args) == 1:
         vec, = args
         dd = dof_desc.DOFDesc("vol", dof_desc.DISCR_TAG_BASE)
@@ -331,34 +712,326 @@ def mass(dcoll, *args):
     else:
         raise TypeError("invalid number of arguments")
 
-    if isinstance(vec, np.ndarray):
-        return obj_array_vectorize(
-                lambda el: mass(dcoll, dd, el), vec)
+    return _apply_mass_operator(dcoll, dof_desc.DD_VOLUME, dd, vec)
 
-    return _bound_mass(dcoll, dd)(u=vec)
+# }}}
 
 
-@memoize_on_first_arg
-def _bound_inverse_mass(dcoll):
-    return bind(dcoll, sym.InverseMassOperator()(sym.Variable("u")),
-            local_only=True)
+# {{{ Mass inverse operator
+
+def reference_inverse_mass_matrix(actx: ArrayContext, element_group):
+    @keyed_memoize_in(
+        actx, reference_inverse_mass_matrix,
+        lambda grp: grp.discretization_key())
+    def get_ref_inv_mass_mat(grp):
+        from modepy import inverse_mass_matrix
+        basis = grp.basis_obj()
+
+        return actx.freeze(
+            actx.from_numpy(
+                np.asarray(
+                    inverse_mass_matrix(basis.functions, grp.unit_nodes),
+                    order="C"
+                )
+            )
+        )
+
+    return get_ref_inv_mass_mat(element_group)
 
 
-def inverse_mass(dcoll, vec):
+def _apply_inverse_mass_operator(
+        dcoll: DiscretizationCollection, dd_out, dd_in, vec):
     if isinstance(vec, np.ndarray):
         return obj_array_vectorize(
-                lambda el: inverse_mass(dcoll, el), vec)
+            lambda vi: _apply_inverse_mass_operator(dcoll,
+                                                    dd_out,
+                                                    dd_in, vi), vec
+        )
+
+    from grudge.geometry import area_element
+
+    if dd_out != dd_in:
+        raise ValueError(
+            "Cannot compute inverse of a mass matrix mapping "
+            "between different element groups; inverse is not "
+            "guaranteed to be well-defined"
+        )
+
+    actx = vec.array_context
+    discr = dcoll.discr_from_dd(dd_in)
+    inv_area_elements = 1./area_element(actx, dcoll, dd=dd_in)
+    group_data = []
+    for grp, jac_inv, vec_i in zip(discr.groups, inv_area_elements, vec):
+
+        ref_mass_inverse = reference_inverse_mass_matrix(actx,
+                                                         element_group=grp)
+
+        # NOTE: Some discretizations can have both affine and non-affine
+        # groups. For example, discretizations on hybrid simplex-hex meshes.
+        if not grp.is_affine:
+            group_data.append(
+                # Based on https://arxiv.org/pdf/1608.03836.pdf
+                # true_Minv ~ ref_Minv * ref_M * (1/jac_det) * ref_Minv
+                actx.einsum("ik,km,em,mj,ej->ei",
+                            # FIXME: Should we manually create a temporary for
+                            # the mass inverse?
+                            ref_mass_inverse,
+                            reference_mass_matrix(
+                                actx,
+                                out_element_group=grp,
+                                in_element_group=grp
+                            ),
+                            jac_inv,
+                            # FIXME: Should we manually create a temporary for
+                            # the mass inverse?
+                            ref_mass_inverse,
+                            vec_i,
+                            tagged=(HasElementwiseMatvecTag(),))
+            )
+        else:
+            group_data.append(
+                actx.einsum("ij,ej,ej->ei",
+                            ref_mass_inverse,
+                            jac_inv,
+                            vec_i,
+                            arg_names=("mass_inv_mat", "jac_det_inv", "vec"),
+                            tagged=(HasElementwiseMatvecTag(),))
+            )
 
-    return _bound_inverse_mass(dcoll)(u=vec)
+    return DOFArray(actx, data=tuple(group_data))
 
 
-@memoize_on_first_arg
-def _bound_face_mass(dcoll, dd):
-    u = sym.Variable("u", dd=dd)
-    return bind(dcoll, sym.FaceMassOperator(dd_in=dd)(u), local_only=True)
+def inverse_mass(dcoll: DiscretizationCollection, vec):
+    r"""Return the action of the DG mass matrix inverse on a vector
+    (or vectors) of :class:`~meshmode.dof_array.DOFArray`\ s, *vec*.
+    In the case of *vec* being an object array of
+    :class:`~meshmode.dof_array.DOFArray`\ s, the inverse mass operator is
+    applied in the Kronecker sense (component-wise).
+
+    For affine elements :math:`E`, the element-wise mass inverse
+    is computed directly as the inverse of the (physical) mass matrix:
+
+    .. math::
+
+        \left(\mathbf{M}_{J^e}\right)_{ij} =
+            \int_{\widehat{E}} \widehat{\phi}_i\cdot\widehat{\phi}_j J^e
+            \mathrm{d}\widehat{x},
+
+    where :math:`\widehat{\phi}_i` are basis functions over the reference
+    element :math:`\widehat{E}`, and :math:`J^e` is the (constant) Jacobian
+    scaling factor (see :func:`grudge.geometry.area_element`).
+
+    For non-affine :math:`E`, :math:`J^e` is not constant. In this case, a
+    weight-adjusted approximation is used instead:
+
+    .. math::
+
+        \mathbf{M}_{J^e}^{-1} \approx
+            \widehat{\mathbf{M}}^{-1}\mathbf{M}_{1/J^e}\widehat{\mathbf{M}}^{-1},
+
+    where :math:`\widehat{\mathbf{M}}` is the reference mass matrix on
+    :math:`\widehat{E}`.
+
+    :arg vec: a :class:`~meshmode.dof_array.DOFArray` or object array of
+        :class:`~meshmode.dof_array.DOFArray`\ s.
+    :returns: a :class:`~meshmode.dof_array.DOFArray` denoting the
+        application of the inverse mass matrix, or an object array of
+        :class:`~meshmode.dof_array.DOFArray`\ s.
+    """
+
+    return _apply_inverse_mass_operator(
+        dcoll, dof_desc.DD_VOLUME, dof_desc.DD_VOLUME, vec
+    )
+
+# }}}
 
 
-def face_mass(dcoll, *args):
+# {{{ Face mass operator
+
+def reference_face_mass_matrix(
+        actx: ArrayContext, face_element_group, vol_element_group, dtype):
+    @keyed_memoize_in(
+        actx, reference_mass_matrix,
+        lambda face_grp, vol_grp: (face_grp.discretization_key(),
+                                   vol_grp.discretization_key()))
+    def get_ref_face_mass_mat(face_grp, vol_grp):
+        nfaces = vol_grp.mesh_el_group.nfaces
+        assert face_grp.nelements == nfaces * vol_grp.nelements
+
+        matrix = np.empty(
+            (vol_grp.nunit_dofs,
+            nfaces,
+            face_grp.nunit_dofs),
+            dtype=dtype
+        )
+
+        import modepy as mp
+        from meshmode.discretization import ElementGroupWithBasis
+        from meshmode.discretization.poly_element import \
+            QuadratureSimplexElementGroup
+
+        n = vol_grp.order
+        m = face_grp.order
+        vol_basis = vol_grp.basis_obj()
+        faces = mp.faces_for_shape(vol_grp.shape)
+
+        for iface, face in enumerate(faces):
+            # If the face group is defined on a higher-order
+            # quadrature grid, use the underlying quadrature rule
+            if isinstance(face_grp, QuadratureSimplexElementGroup):
+                face_quadrature = face_grp.quadrature_rule()
+                if face_quadrature.exact_to < m:
+                    raise ValueError(
+                        "The face quadrature rule is only exact for polynomials "
+                        f"of total degree {face_quadrature.exact_to}. Please "
+                        "ensure a quadrature rule is used that is at least "
+                        f"exact for degree {m}."
+                    )
+            else:
+                # NOTE: This handles the general case where
+                # volume and surface quadrature rules may have different
+                # integration orders
+                face_quadrature = mp.quadrature_for_space(
+                    mp.space_for_shape(face, 2*max(n, m)),
+                    face
+                )
+
+            # If the group has a nodal basis and is unisolvent,
+            # we use the basis on the face to compute the face mass matrix
+            if (isinstance(face_grp, ElementGroupWithBasis)
+                    and face_grp.space.space_dim == face_grp.nunit_dofs):
+
+                face_basis = face_grp.basis_obj()
+
+                # Sanity check for face quadrature accuracy. Not integrating
+                # degree N + M polynomials here is asking for a bad time.
+                if face_quadrature.exact_to < m + n:
+                    raise ValueError(
+                        "The face quadrature rule is only exact for polynomials "
+                        f"of total degree {face_quadrature.exact_to}. Please "
+                        "ensure a quadrature rule is used that is at least "
+                        f"exact for degree {n+m}."
+                    )
+
+                matrix[:, iface, :] = mp.nodal_mass_matrix_for_face(
+                    face, face_quadrature,
+                    face_basis.functions, vol_basis.functions,
+                    vol_grp.unit_nodes,
+                    face_grp.unit_nodes,
+                )
+            else:
+                # Otherwise, we use a routine that is purely quadrature-based
+                # (no need for explicit face basis functions)
+                matrix[:, iface, :] = mp.nodal_quad_mass_matrix_for_face(
+                    face,
+                    face_quadrature,
+                    vol_basis.functions,
+                    vol_grp.unit_nodes,
+                )
+
+        return actx.freeze(actx.from_numpy(matrix))
+
+    return get_ref_face_mass_mat(face_element_group, vol_element_group)
+
+
+def _apply_face_mass_operator(dcoll: DiscretizationCollection, dd, vec):
+    if isinstance(vec, np.ndarray):
+        return obj_array_vectorize(
+            lambda vi: _apply_face_mass_operator(dcoll, dd, vi), vec
+        )
+
+    from grudge.geometry import area_element
+
+    volm_discr = dcoll.discr_from_dd(dof_desc.DD_VOLUME)
+    face_discr = dcoll.discr_from_dd(dd)
+    dtype = vec.entry_dtype
+    actx = vec.array_context
+
+    @memoize_in(actx, (_apply_face_mass_operator, "face_mass_knl"))
+    def prg():
+        return make_loopy_program(
+            [
+                "{[iel]: 0 <= iel < nelements}",
+                "{[f]: 0 <= f < nfaces}",
+                "{[idof]: 0 <= idof < nvol_nodes}",
+                "{[jdof]: 0 <= jdof < nface_nodes}"
+            ],
+            """
+            result[iel, idof] = sum(f, sum(jdof, mat[idof, f, jdof]
+                                                 * jac_surf[f, iel, jdof]
+                                                 * vec[f, iel, jdof]))
+            """,
+            name="face_mass"
+        )
+
+    assert len(face_discr.groups) == len(volm_discr.groups)
+    surf_area_elements = area_element(actx, dcoll, dd=dd)
+
+    return DOFArray(
+        actx,
+        data=tuple(
+            actx.call_loopy(prg(),
+                            mat=reference_face_mass_matrix(
+                                actx,
+                                face_element_group=afgrp,
+                                vol_element_group=vgrp,
+                                dtype=dtype
+                            ),
+                            jac_surf=surf_ae_i.reshape(
+                                vgrp.mesh_el_group.nfaces,
+                                vgrp.nelements,
+                                afgrp.nunit_dofs
+                            ),
+                            vec=vec_i.reshape(
+                                vgrp.mesh_el_group.nfaces,
+                                vgrp.nelements,
+                                afgrp.nunit_dofs
+                            ))["result"]
+
+            for vgrp, afgrp, vec_i, surf_ae_i in zip(volm_discr.groups,
+                                                     face_discr.groups,
+                                                     vec,
+                                                     surf_area_elements)
+        )
+    )
+
+
+def face_mass(dcoll: DiscretizationCollection, *args):
+    r"""Return the action of the DG face mass matrix on a vector (or vectors)
+    of :class:`~meshmode.dof_array.DOFArray`\ s, *vec*. In the case of
+    *vec* being an object array of :class:`~meshmode.dof_array.DOFArray`\ s,
+    the mass operator is applied in the Kronecker sense (component-wise).
+
+    May be called with ``(vec)`` or ``(dd, vec)``.
+
+    Specifically, this function applies the face mass matrix elementwise on a
+    vector of coefficients :math:`\mathbf{f}` as the sum of contributions for
+    each face :math:`f \subset \partial E`:
+
+    .. math::
+
+        \sum_{f=1}^{N_{\text{faces}}} \mathbf{M}_{f, E}\mathbf{f}|_f,
+
+    where
+
+    .. math::
+
+        \left(\mathbf{M}_{f, E}\right)_{ij} =
+            \int_{f \subset \partial E} \phi_i(s)\psi_j(s)\,\mathrm{d}s,
+
+    where :math:`\phi_i` are (volume) polynomial basis functions on :math:`E`
+    evaluated on the face :math:`f`, and :math:`\psi_j` are basis functions for
+    a polynomial space defined on :math:`f`.
+
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+        Defaults to the base ``"all_faces"`` discretization if not provided.
+    :arg vec: a :class:`~meshmode.dof_array.DOFArray` or object array of
+        :class:`~meshmode.dof_array.DOFArray`\ s.
+    :returns: a :class:`~meshmode.dof_array.DOFArray` denoting the
+        application of the face mass matrix, or an object array of
+        :class:`~meshmode.dof_array.DOFArray`\ s.
+    """
+
     if len(args) == 1:
         vec, = args
         dd = dof_desc.DOFDesc("all_faces", dof_desc.DISCR_TAG_BASE)
@@ -367,27 +1040,49 @@ def face_mass(dcoll, *args):
     else:
         raise TypeError("invalid number of arguments")
 
-    if isinstance(vec, np.ndarray):
-        return obj_array_vectorize(
-                lambda el: face_mass(dcoll, dd, el), vec)
-
-    return _bound_face_mass(dcoll, dd)(u=vec)
+    return _apply_face_mass_operator(dcoll, dd, vec)
 
 # }}}
 
 
-# {{{ reductions
+# {{{ Nodal reductions
 
-@memoize_on_first_arg
-def _norm(dcoll, p, dd):
-    return bind(dcoll,
-            sym.norm(p, sym.var("arg", dd=dd), dd=dd),
-            local_only=True)
+def _norm(dcoll: DiscretizationCollection, vec, p, dd):
+    if isinstance(vec, Number):
+        return np.fabs(vec)
+    if p == 2:
+        return np.sqrt(
+            nodal_sum(
+                dcoll,
+                dd,
+                vec * _apply_mass_operator(dcoll, dd, dd, vec)
+            )
+        )
+    elif p == np.inf:
+        return nodal_max(dcoll, dd, dcoll._setup_actx.np.fabs(vec))
+    else:
+        raise NotImplementedError("Unsupported value of p")
 
 
-def norm(dcoll, vec, p, dd=None):
+def norm(dcoll: DiscretizationCollection, vec, p, dd=None):
+    r"""Return the vector p-norm of a function represented
+    by its vector of degrees of freedom *vec*.
+
+    :arg vec: a :class:`~meshmode.dof_array.DOFArray` or 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.
+    :arg p: an integer denoting the order of the integral norm. Currently,
+        only values of 2 or `numpy.inf` are supported.
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+        Defaults to the base volume discretization if not provided.
+    :returns: a nonegative scalar denoting the norm.
+    """
+    # FIXME: Make MPI-aware
+    # NOTE: Must retain a way to do local reductions
+
     if dd is None:
-        dd = "vol"
+        dd = dof_desc.DD_VOLUME
 
     dd = dof_desc.as_dofdesc(dd)
 
@@ -403,155 +1098,132 @@ def norm(dcoll, vec, p, dd=None):
         else:
             raise ValueError("unsupported norm order")
 
-    return _norm(dcoll, p, dd)(arg=vec)
-
+    return _norm(dcoll, vec, p, dd)
 
-@memoize_on_first_arg
-def _nodal_reduction(dcoll, operator, dd):
-    return bind(dcoll, operator(dd)(sym.var("arg")), local_only=True)
 
+def nodal_sum(dcoll: DiscretizationCollection, dd, vec):
+    r"""Return the nodal sum of a vector of degrees of freedom *vec*.
 
-def nodal_sum(dcoll, dd, vec):
-    return _nodal_reduction(dcoll, sym.NodalSum, dd)(arg=vec)
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value
+        convertible to one.
+    :arg vec: a :class:`~meshmode.dof_array.DOFArray`.
+    :returns: a scalar denoting the nodal sum.
+    """
+    # FIXME: Make MPI-aware
+    # NOTE: Must retain a way to do local reductions
+    actx = vec.array_context
+    return sum([actx.np.sum(grp_ary) for grp_ary in vec])
 
 
-def nodal_min(dcoll, dd, vec):
-    return _nodal_reduction(dcoll, sym.NodalMin, dd)(arg=vec)
+def nodal_min(dcoll: DiscretizationCollection, dd, vec):
+    r"""Return the nodal minimum of a vector of degrees of freedom *vec*.
 
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value
+        convertible to one.
+    :arg vec: a :class:`~meshmode.dof_array.DOFArray`.
+    :returns: a scalar denoting the nodal minimum.
+    """
+    # FIXME: Make MPI-aware
+    # NOTE: Must retain a way to do local reductions
+    actx = vec.array_context
+    return reduce(lambda acc, grp_ary: actx.np.minimum(acc, actx.np.min(grp_ary)),
+                  vec, -np.inf)
 
-def nodal_max(dcoll, dd, vec):
-    return _nodal_reduction(dcoll, sym.NodalMax, dd)(arg=vec)
 
-# }}}
+def nodal_max(dcoll: DiscretizationCollection, dd, vec):
+    r"""Return the nodal maximum of a vector of degrees of freedom *vec*.
 
-
-@memoize_on_first_arg
-def connected_ranks(dcoll):
-    from meshmode.distributed import get_connected_partitions
-    return get_connected_partitions(dcoll._volume_discr.mesh)
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value
+        convertible to one.
+    :arg vec: a :class:`~meshmode.dof_array.DOFArray`.
+    :returns: a scalar denoting the nodal maximum.
+    """
+    # FIXME: Make MPI-aware
+    # NOTE: Must retain a way to do local reductions
+    actx = vec.array_context
+    return reduce(lambda acc, grp_ary: actx.np.maximum(acc, actx.np.max(grp_ary)),
+                  vec, -np.inf)
 
 
-# {{{ interior_trace_pair
+def integral(dcoll: DiscretizationCollection, dd, vec):
+    """Numerically integrates a function represented by a
+    :class:`~meshmode.dof_array.DOFArray` of degrees of freedom.
 
-def interior_trace_pair(dcoll, vec):
-    """Return a :class:`grudge.sym.TracePair` for the interior faces of
-    *dcoll*.
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+    :arg vec: a :class:`~meshmode.dof_array.DOFArray`
+    :returns: a scalar denoting the evaluated integral.
     """
-    i = project(dcoll, "vol", "int_faces", vec)
-
-    def get_opposite_face(el):
-        if isinstance(el, Number):
-            return el
-        else:
-            return dcoll.opposite_face_connection()(el)
 
-    e = obj_array_vectorize(get_opposite_face, i)
+    dd = dof_desc.as_dofdesc(dd)
 
-    return TracePair("int_faces", interior=i, exterior=e)
+    ones = dcoll.discr_from_dd(dd).zeros(vec.array_context) + 1.0
+    return nodal_sum(
+        dcoll, dd, vec * _apply_mass_operator(dcoll, dd, dd, ones)
+    )
 
 # }}}
 
 
-# {{{ distributed-memory functionality
-
-class _RankBoundaryCommunication:
-    base_tag = 1273
-
-    def __init__(self, dcoll, remote_rank, vol_field, tag=None):
-        self.tag = self.base_tag
-        if tag is not None:
-            self.tag += tag
-
-        self.dcoll = dcoll
-        self.array_context = vol_field.array_context
-        self.remote_btag = BTAG_PARTITION(remote_rank)
-
-        self.bdry_discr = dcoll.discr_from_dd(self.remote_btag)
-        self.local_dof_array = project(dcoll, "vol", self.remote_btag, vol_field)
+# {{{  Elementwise reductions
 
-        local_data = self.array_context.to_numpy(flatten(self.local_dof_array))
+def _map_elementwise_reduction(actx: ArrayContext, op_name):
+    @memoize_in(actx, (_map_elementwise_reduction,
+                       "elementwise_%s_prg" % op_name))
+    def prg():
+        return make_loopy_program(
+            [
+                "{[iel]: 0 <= iel < nelements}",
+                "{[idof, jdof]: 0 <= idof, jdof < ndofs}"
+            ],
+            """
+                result[iel, idof] = %s(jdof, operand[iel, jdof])
+            """ % op_name,
+            name="grudge_elementwise_%s_knl" % op_name
+        )
+    return prg()
 
-        comm = self.dcoll.mpi_communicator
 
-        self.send_req = comm.Isend(
-                local_data, remote_rank, tag=self.tag)
+def elementwise_sum(dcoll: DiscretizationCollection, *args):
+    r"""Returns a vector of DOFs with all entries on each element set
+    to the sum of DOFs on that element.
 
-        self.remote_data_host = np.empty_like(local_data)
-        self.recv_req = comm.Irecv(self.remote_data_host, remote_rank, self.tag)
-
-    def finish(self):
-        self.recv_req.Wait()
-
-        actx = self.array_context
-        remote_dof_array = unflatten(self.array_context, self.bdry_discr,
-                actx.from_numpy(self.remote_data_host))
-
-        bdry_conn = self.dcoll.get_distributed_boundary_swap_connection(
-                dof_desc.as_dofdesc(dof_desc.DTAG_BOUNDARY(self.remote_btag)))
-        swapped_remote_dof_array = bdry_conn(remote_dof_array)
-
-        self.send_req.Wait()
-
-        return TracePair(self.remote_btag,
-                interior=self.local_dof_array,
-                exterior=swapped_remote_dof_array)
+    May be called with ``(dcoll, vec)`` or ``(dcoll, dd, vec)``.
 
+    :arg dd: a :class:`~grudge.dof_desc.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: a :class:`~meshmode.dof_array.DOFArray` whose entries
+        denote the element-wise sum of *vec*.
+    """
 
-def _cross_rank_trace_pairs_scalar_field(dcoll, vec, tag=None):
-    if isinstance(vec, Number):
-        return [TracePair(BTAG_PARTITION(remote_rank), interior=vec, exterior=vec)
-                for remote_rank in connected_ranks(dcoll)]
+    if len(args) == 1:
+        vec, = args
+        dd = dof_desc.DOFDesc("vol", dof_desc.DISCR_TAG_BASE)
+    elif len(args) == 2:
+        dd, vec = args
     else:
-        rbcomms = [_RankBoundaryCommunication(dcoll, remote_rank, vec, tag=tag)
-                for remote_rank in connected_ranks(dcoll)]
-        return [rbcomm.finish() for rbcomm in rbcomms]
-
-
-def cross_rank_trace_pairs(dcoll, ary, tag=None):
-    r"""Get a list of *ary* trace pairs for each partition boundary.
-
-    For each partition boundary, the field data values in *ary* are
-    communicated to/from the neighboring partition. Presumably, this
-    communication is MPI (but strictly speaking, may not be, and this
-    routine is agnostic to the underlying communication, see e.g.
-    _cross_rank_trace_pairs_scalar_field).
+        raise TypeError("invalid number of arguments")
 
-    For each face on each partition boundary, a :class:`TracePair` is
-    created with the locally, and remotely owned partition boundary face
-    data as the `internal`, and `external` components, respectively.
-    Each of the TracePair components are structured like *ary*.
+    dd = dof_desc.as_dofdesc(dd)
 
-    The input field data *ary* may be a single
-    :class:`~meshmode.dof_array.DOFArray`, or an object
-    array of ``DOFArray``\ s of arbitrary shape.
-    """
-    if isinstance(ary, np.ndarray):
-        oshape = ary.shape
-        comm_vec = ary.flatten()
-
-        n, = comm_vec.shape
-        result = {}
-        # FIXME: Batch this communication rather than
-        # doing it in sequence.
-        for ivec in range(n):
-            for rank_tpair in _cross_rank_trace_pairs_scalar_field(
-                    dcoll, comm_vec[ivec]):
-                assert isinstance(rank_tpair.dd.domain_tag, dof_desc.DTAG_BOUNDARY)
-                assert isinstance(rank_tpair.dd.domain_tag.tag, BTAG_PARTITION)
-                result[rank_tpair.dd.domain_tag.tag.part_nr, ivec] = rank_tpair
-
-        return [
-            TracePair(
-                dd=dof_desc.as_dofdesc(
-                    dof_desc.DTAG_BOUNDARY(BTAG_PARTITION(remote_rank))),
-                interior=make_obj_array([
-                    result[remote_rank, i].int for i in range(n)]).reshape(oshape),
-                exterior=make_obj_array([
-                    result[remote_rank, i].ext for i in range(n)]).reshape(oshape)
-                )
-            for remote_rank in connected_ranks(dcoll)]
-    else:
-        return _cross_rank_trace_pairs_scalar_field(dcoll, ary, tag=tag)
+    if isinstance(vec, np.ndarray):
+        return obj_array_vectorize(
+            lambda vi: elementwise_sum(dcoll, dd, vi), vec
+        )
+
+    actx = vec.array_context
+    vec = project(dcoll, "vol", dd, vec)
+
+    return DOFArray(
+        actx,
+        data=tuple(
+            actx.call_loopy(
+                _map_elementwise_reduction(actx, "sum"),
+                operand=vec_i
+            )["result"]
+            for vec_i in vec
+        )
+    )
 
 # }}}
 
diff --git a/grudge/symbolic/primitives.py b/grudge/symbolic/primitives.py
index c880d9d0925d0b8c48a01b788cf81249160865e9..d95bc657b4f7f1c848f9eb116f4e4fcb2b1ed1c2 100644
--- a/grudge/symbolic/primitives.py
+++ b/grudge/symbolic/primitives.py
@@ -34,6 +34,7 @@ from pymbolic.primitives import (
         cse_scope as cse_scope_base,
         make_common_subexpression as cse)
 from pymbolic.geometric_algebra import MultiVector
+from grudge.trace_pair import TracePair
 
 
 class ExpressionBase(prim.Expression):
@@ -94,10 +95,9 @@ Geometry data
 .. autofunction:: summed_curvature
 .. autofunction:: mean_curvature
 
-Trace Pair
-^^^^^^^^^^
+Symbolic trace pair functions
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
-.. autoclass:: TracePair
 .. autofunction:: int_tpair
 .. autofunction:: bv_tpair
 .. autofunction:: bdry_tpair
@@ -668,64 +668,7 @@ def mean_curvature(ambient_dim, dim=None, dd=None):
 # }}}
 
 
-# {{{ trace pair
-
-class TracePair:
-    """
-    .. attribute:: dd
-
-        an instance of :class:`grudge.dof_desc.DOFDesc` describing the
-        discretization on which :attr:`interior` and :attr:`exterior`
-        live.
-
-    .. attribute:: interior
-
-        a value (symbolic expression or :class:`~meshmode.dof_array.DOFArray`
-        or object array of either) representing the interior value to
-        be used for the flux.
-
-    .. attribute:: exterior
-
-        a value (symbolic expression or :class:`~meshmode.dof_array.DOFArray`
-        or object array of either) representing the exterior value to
-        be used for the flux.
-
-    .. note::
-
-        :class:`TracePair` is used both by the symbolic and the eager interface,
-        with symbolic information or concrete data.
-    """
-    def __init__(self, dd, *, interior, exterior):
-        """
-        """
-        import grudge.dof_desc as dof_desc
-
-        self.dd = dof_desc.as_dofdesc(dd)
-        self.interior = interior
-        self.exterior = exterior
-
-    def __getitem__(self, index):
-        return TracePair(
-                self.dd,
-                interior=self.interior[index],
-                exterior=self.exterior[index])
-
-    def __len__(self):
-        assert len(self.exterior) == len(self.interior)
-        return len(self.exterior)
-
-    @property
-    def int(self):
-        return self.interior
-
-    @property
-    def ext(self):
-        return self.exterior
-
-    @property
-    def avg(self):
-        return 0.5*(self.int + self.ext)
-
+# {{{ Symbolic trace pair functions
 
 def int_tpair(expression, qtag=None, from_dd=None):
     from meshmode.discretization.connection import FACE_RESTR_INTERIOR
diff --git a/grudge/trace_pair.py b/grudge/trace_pair.py
new file mode 100644
index 0000000000000000000000000000000000000000..ad2e9c815f6b38d48f7dabf95314bdfceda553fc
--- /dev/null
+++ b/grudge/trace_pair.py
@@ -0,0 +1,375 @@
+__copyright__ = """
+Copyright (C) 2021 University of Illinois Board of Trustees
+"""
+
+__license__ = """
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+"""
+
+
+from arraycontext import (
+    ArrayContainer,
+    with_container_arithmetic,
+    dataclass_array_container
+)
+
+from dataclasses import dataclass
+
+from numbers import Number
+
+from pytools import memoize_on_first_arg
+from pytools.obj_array import obj_array_vectorize, make_obj_array
+
+from grudge.discretization import DiscretizationCollection
+
+from meshmode.dof_array import flatten, unflatten
+from meshmode.mesh import BTAG_PARTITION
+
+import numpy as np
+import grudge.dof_desc as dof_desc
+
+
+__doc__ = """
+Trace Pairs
+^^^^^^^^^^^
+
+Container class
+---------------
+
+.. autoclass:: TracePair
+
+Boundary trace functions
+------------------------
+
+.. autofunction:: bdry_trace_pair
+.. autofunction:: bv_trace_pair
+
+Interior and cross-rank trace functions
+---------------------------------------
+
+.. autofunction:: interior_trace_pairs
+.. autofunction:: cross_rank_trace_pairs
+"""
+
+
+# {{{ Trace pair container class
+
+@with_container_arithmetic(
+    bcast_obj_array=False, eq_comparison=False, rel_comparison=False
+)
+@dataclass_array_container
+@dataclass(init=False, frozen=True)
+class TracePair:
+    """A container class for data (both interior and exterior restrictions)
+    on the boundaries of mesh elements.
+
+    .. attribute:: dd
+
+        an instance of :class:`grudge.dof_desc.DOFDesc` describing the
+        discretization on which :attr:`int` and :attr:`ext` live.
+
+    .. autoattribute:: int
+    .. autoattribute:: ext
+    .. autoattribute:: avg
+
+    .. automethod:: __getattr__
+    .. automethod:: __getitem__
+    .. automethod:: __len__
+
+    .. note::
+
+        :class:`TracePair` is currently used both by the symbolic (deprecated)
+        and the current interfaces, with symbolic information or concrete data.
+    """
+
+    dd: dof_desc.DOFDesc
+    interior: ArrayContainer
+    exterior: ArrayContainer
+
+    def __init__(self, dd, *, interior, exterior):
+        object.__setattr__(self, "dd", dof_desc.as_dofdesc(dd))
+        object.__setattr__(self, "interior", interior)
+        object.__setattr__(self, "exterior", exterior)
+
+    def __getattr__(self, name):
+        """Return a new :class:`TracePair` resulting from executing attribute
+        lookup with *name* on :attr:`int` and :attr:`ext`.
+        """
+        return TracePair(self.dd,
+                         interior=getattr(self.interior, name),
+                         exterior=getattr(self.exterior, name))
+
+    def __getitem__(self, index):
+        """Return a new :class:`TracePair` resulting from executing
+        subscripting with *index* on :attr:`int` and :attr:`ext`.
+        """
+        return TracePair(self.dd,
+                         interior=self.interior[index],
+                         exterior=self.exterior[index])
+
+    def __len__(self):
+        """Return the total number of arrays associated with the
+        :attr:`int` and :attr:`ext` restrictions of the :class:`TracePair`.
+        Note that both must be the same.
+        """
+        assert len(self.exterior) == len(self.interior)
+        return len(self.exterior)
+
+    @property
+    def int(self):
+        """A value (symbolic expression or :class:`~meshmode.dof_array.DOFArray`
+        or object array of either) representing the interior value to
+        be used for the flux.
+        """
+        return self.interior
+
+    @property
+    def ext(self):
+        """A value (symbolic expression or :class:`~meshmode.dof_array.DOFArray`
+        or object array of either) representing the exterior value to
+        be used for the flux.
+        """
+        return self.exterior
+
+    @property
+    def avg(self):
+        """A value (symbolic expression or :class:`~meshmode.dof_array.DOFArray`
+        or object array of either) representing the average of the interior
+        and exterior values.
+        """
+        return 0.5 * (self.int + self.ext)
+
+# }}}
+
+
+# {{{ Boundary trace pairs
+
+def bdry_trace_pair(
+        dcoll: DiscretizationCollection, dd, interior, exterior) -> TracePair:
+    """Returns a trace pair defined on the exterior boundary. Input arguments
+    are assumed to already be defined on the boundary denoted by *dd*.
+
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one,
+        which describes the boundary discretization.
+    :arg interior: a :class:`~meshmode.dof_array.DOFArray` that contains data
+        already on the boundary representing the interior value to be used
+        for the flux.
+    :arg exterior: a :class:`~meshmode.dof_array.DOFArray` that contains data
+        that already lives on the boundary representing the exterior value to
+        be used for the flux.
+    :returns: a :class:`TracePair` on the boundary.
+    """
+    return TracePair(dd, interior=interior, exterior=exterior)
+
+
+def bv_trace_pair(
+        dcoll: DiscretizationCollection, dd, interior, exterior) -> TracePair:
+    """Returns a trace pair defined on the exterior boundary. The interior
+    argument is assumed to be defined on the volume discretization, and will
+    therefore be restricted to the boundary *dd* prior to creating a
+    :class:`TracePair`.
+
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one,
+        which describes the boundary discretization.
+    :arg interior: a :class:`~meshmode.dof_array.DOFArray` that contains data
+        defined in the volume, which will be restricted to the boundary denoted
+        by *dd*. The result will be used as the interior value
+        for the flux.
+    :arg exterior: a :class:`~meshmode.dof_array.DOFArray` that contains data
+        that already lives on the boundary representing the exterior value to
+        be used for the flux.
+    :returns: a :class:`TracePair` on the boundary.
+    """
+    from grudge.op import project
+
+    interior = project(dcoll, "vol", dd, interior)
+    return bdry_trace_pair(dcoll, dd, interior, exterior)
+
+# }}}
+
+
+# {{{ Interior trace pairs
+
+def _interior_trace_pair(dcoll: DiscretizationCollection, vec) -> TracePair:
+    r"""Return a :class:`TracePair` for the interior faces of
+    *dcoll* with a discretization tag specified by *discr_tag*.
+    This does not include interior faces on different MPI ranks.
+
+    :arg vec: a :class:`~meshmode.dof_array.DOFArray` or object array of
+        :class:`~meshmode.dof_array.DOFArray`\ s.
+    :returns: a :class:`TracePair` object.
+    """
+    from grudge.op import project
+
+    i = project(dcoll, "vol", "int_faces", vec)
+
+    def get_opposite_face(el):
+        if isinstance(el, Number):
+            return el
+        else:
+            return dcoll.opposite_face_connection()(el)
+
+    e = obj_array_vectorize(get_opposite_face, i)
+
+    return TracePair("int_faces", interior=i, exterior=e)
+
+
+def interior_trace_pairs(dcoll: DiscretizationCollection, vec) -> list:
+    r"""Return a :class:`list` of :class:`TracePair` objects
+    defined on the interior faces of *dcoll* and any faces connected to a
+    parallel boundary.
+
+    :arg vec: a :class:`~meshmode.dof_array.DOFArray` or object array of
+        :class:`~meshmode.dof_array.DOFArray`\ s.
+    :returns: a :class:`list` of :class:`TracePair` objects.
+    """
+    return (
+        [_interior_trace_pair(dcoll, vec)]
+        + cross_rank_trace_pairs(dcoll, vec)
+    )
+
+
+def interior_trace_pair(dcoll: DiscretizationCollection, vec) -> TracePair:
+    from warnings import warn
+    warn("`grudge.op.interior_trace_pair` is deprecated and will be dropped "
+         "in version 2022.x. Use `grudge.trace_pair.interior_trace_pairs` "
+         "instead, which includes contributions from different MPI ranks.",
+         DeprecationWarning, stacklevel=2)
+    return _interior_trace_pair(dcoll, vec)
+
+# }}}
+
+
+# {{{ Distributed-memory functionality
+
+@memoize_on_first_arg
+def connected_ranks(dcoll: DiscretizationCollection):
+    from meshmode.distributed import get_connected_partitions
+    return get_connected_partitions(dcoll._volume_discr.mesh)
+
+
+class _RankBoundaryCommunication:
+    base_tag = 1273
+
+    def __init__(self, dcoll: DiscretizationCollection,
+                 remote_rank, vol_field, tag=None):
+        self.tag = self.base_tag
+        if tag is not None:
+            self.tag += tag
+
+        self.dcoll = dcoll
+        self.array_context = vol_field.array_context
+        self.remote_btag = BTAG_PARTITION(remote_rank)
+        self.bdry_discr = dcoll.discr_from_dd(self.remote_btag)
+
+        from grudge.op import project
+
+        self.local_dof_array = project(dcoll, "vol", self.remote_btag, vol_field)
+
+        local_data = self.array_context.to_numpy(flatten(self.local_dof_array))
+        comm = self.dcoll.mpi_communicator
+
+        self.send_req = comm.Isend(local_data, remote_rank, tag=self.tag)
+        self.remote_data_host = np.empty_like(local_data)
+        self.recv_req = comm.Irecv(self.remote_data_host, remote_rank, self.tag)
+
+    def finish(self):
+        self.recv_req.Wait()
+
+        actx = self.array_context
+        remote_dof_array = unflatten(
+            self.array_context, self.bdry_discr,
+            actx.from_numpy(self.remote_data_host)
+        )
+
+        bdry_conn = self.dcoll.distributed_boundary_swap_connection(
+            dof_desc.as_dofdesc(dof_desc.DTAG_BOUNDARY(self.remote_btag))
+        )
+        swapped_remote_dof_array = bdry_conn(remote_dof_array)
+
+        self.send_req.Wait()
+
+        return TracePair(self.remote_btag,
+                         interior=self.local_dof_array,
+                         exterior=swapped_remote_dof_array)
+
+
+def _cross_rank_trace_pairs_scalar_field(
+        dcoll: DiscretizationCollection, vec, tag=None) -> list:
+    if isinstance(vec, Number):
+        return [TracePair(BTAG_PARTITION(remote_rank), interior=vec, exterior=vec)
+                for remote_rank in connected_ranks(dcoll)]
+    else:
+        rbcomms = [_RankBoundaryCommunication(dcoll, remote_rank, vec, tag=tag)
+                   for remote_rank in connected_ranks(dcoll)]
+        return [rbcomm.finish() for rbcomm in rbcomms]
+
+
+def cross_rank_trace_pairs(
+        dcoll: DiscretizationCollection, ary, tag=None) -> list:
+    r"""Get a :class:`list` of *ary* trace pairs for each partition boundary.
+
+    For each partition boundary, the field data values in *ary* are
+    communicated to/from the neighboring partition. Presumably, this
+    communication is MPI (but strictly speaking, may not be, and this
+    routine is agnostic to the underlying communication).
+
+    For each face on each partition boundary, a
+    :class:`TracePair` is created with the locally, and
+    remotely owned partition boundary face data as the `internal`, and `external`
+    components, respectively. Each of the TracePair components are structured
+    like *ary*.
+
+    :arg ary: a single :class:`~meshmode.dof_array.DOFArray`, or an object
+        array of :class:`~meshmode.dof_array.DOFArray`\ s
+        of arbitrary shape.
+    :returns: a :class:`list` of :class:`TracePair` objects.
+    """
+    if isinstance(ary, np.ndarray):
+        oshape = ary.shape
+        comm_vec = ary.flatten()
+
+        n, = comm_vec.shape
+        result = {}
+        # FIXME: Batch this communication rather than
+        # doing it in sequence.
+        for ivec in range(n):
+            for rank_tpair in _cross_rank_trace_pairs_scalar_field(
+                    dcoll, comm_vec[ivec]):
+                assert isinstance(rank_tpair.dd.domain_tag, dof_desc.DTAG_BOUNDARY)
+                assert isinstance(rank_tpair.dd.domain_tag.tag, BTAG_PARTITION)
+                result[rank_tpair.dd.domain_tag.tag.part_nr, ivec] = rank_tpair
+
+        return [
+            TracePair(
+                dd=dof_desc.as_dofdesc(
+                    dof_desc.DTAG_BOUNDARY(BTAG_PARTITION(remote_rank))),
+                interior=make_obj_array([
+                    result[remote_rank, i].int for i in range(n)]).reshape(oshape),
+                exterior=make_obj_array([
+                    result[remote_rank, i].ext for i in range(n)]).reshape(oshape)
+            ) for remote_rank in connected_ranks(dcoll)
+        ]
+    else:
+        return _cross_rank_trace_pairs_scalar_field(dcoll, ary, tag=tag)
+
+# }}}
+
+
+# vim: foldmethod=marker
diff --git a/setup.cfg b/setup.cfg
index 38dcd9de2c01e21191ca1612388f36ecbc251b70..9d6debd0c7358440a314c9f4f17ced3e4fb260e6 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -2,11 +2,6 @@
 ignore = E126,E127,E128,E123,E226,E241,E242,E265,W503,E402
 max-line-length=85
 exclude=
-  grudge/models/gas_dynamics,
-  grudge/models/burgers.py,
-  grudge/models/pml.py,
-  grudge/models/diffusion.py,
-  grudge/models/nd_calculus.py,
   grudge/dt_finding.py
 
 inline-quotes = "
diff --git a/test/mesh_data.py b/test/mesh_data.py
index 5529856eb5f01f135d8f546ceca7750adced02af..b6effe4c0d303f3d0b0f35286bf0e21ef5a926aa 100644
--- a/test/mesh_data.py
+++ b/test/mesh_data.py
@@ -113,7 +113,7 @@ class BoxMeshBuilder(MeshBuilder):
     ambient_dim = 2
 
     mesh_order = 1
-    resolutions = [8, 16, 32]
+    resolutions = [4, 8, 16]
 
     a = (-0.5, -0.5, -0.5)
     b = (+0.5, +0.5, +0.5)
diff --git a/test/test_grudge.py b/test/test_grudge.py
index f246a895c2bf2b1229986c52c152d405aebf68b4..41a52edf22e30f4121350ed3575c825ebc5018ae 100644
--- a/test/test_grudge.py
+++ b/test/test_grudge.py
@@ -1,4 +1,7 @@
-__copyright__ = "Copyright (C) 2015 Andreas Kloeckner"
+__copyright__ = """
+Copyright (C) 2015 Andreas Kloeckner
+Copyright (C) 2021 University of Illinois Board of Trustees
+"""
 
 __license__ = """
 Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -23,20 +26,25 @@ THE SOFTWARE.
 import numpy as np
 import numpy.linalg as la
 
+from arraycontext import (  # noqa
+    pytest_generate_tests_for_pyopencl_array_context
+    as pytest_generate_tests
+)
+from arraycontext.container.traversal import thaw
+
 from meshmode import _acf       # noqa: F401
-from meshmode.dof_array import flatten, thaw
+from meshmode.dof_array import flat_norm
 import meshmode.mesh.generation as mgen
 
 from pytools.obj_array import flat_obj_array, make_obj_array
 
-from grudge import sym, bind, DiscretizationCollection
+from grudge import DiscretizationCollection
 
 import grudge.dof_desc as dof_desc
+import grudge.op as op
+
 
 import pytest
-from meshmode.array_context import (  # noqa
-        pytest_generate_tests_for_pyopencl_array_context
-        as pytest_generate_tests)
 
 import logging
 
@@ -67,23 +75,19 @@ def test_inverse_metric(actx_factory, dim):
     from meshmode.mesh.processing import map_mesh
     mesh = map_mesh(mesh, m)
 
-    discr = DiscretizationCollection(actx, mesh, order=4)
+    dcoll = DiscretizationCollection(actx, mesh, order=4)
 
-    sym_op = (
-            sym.forward_metric_derivative_mat(mesh.dim)
-            .dot(
-                sym.inverse_metric_derivative_mat(mesh.dim)
-                )
-            .reshape(-1))
+    from grudge.geometry import \
+        forward_metric_derivative_mat, inverse_metric_derivative_mat
 
-    op = bind(discr, sym_op)
-    mat = op(actx).reshape(mesh.dim, mesh.dim)
+    mat = forward_metric_derivative_mat(actx, dcoll).dot(
+        inverse_metric_derivative_mat(actx, dcoll))
 
     for i in range(mesh.dim):
         for j in range(mesh.dim):
             tgt = 1 if i == j else 0
 
-            err = actx.np.linalg.norm(mat[i, j] - tgt, ord=np.inf)
+            err = flat_norm(mat[i, j] - tgt, ord=np.inf)
             logger.info("error[%d, %d]: %.5e", i, j, err)
             assert err < 1.0e-12, (i, j, err)
 
@@ -120,48 +124,49 @@ def test_mass_mat_trig(actx_factory, ambient_dim, discr_tag):
     mesh = mgen.generate_regular_rect_mesh(
             a=(a,)*ambient_dim, b=(b,)*ambient_dim,
             nelements_per_axis=(nel_1d,)*ambient_dim, order=1)
-    discr = DiscretizationCollection(
+    dcoll = DiscretizationCollection(
         actx, mesh, order=order,
         discr_tag_to_group_factory=discr_tag_to_group_factory
     )
 
-    def _get_variables_on(dd):
-        sym_f = sym.var("f", dd=dd)
-        sym_x = sym.nodes(ambient_dim, dd=dd)
-        sym_ones = sym.Ones(dd)
-
-        return sym_f, sym_x, sym_ones
+    def f(x):
+        return actx.np.sin(x[0])**2
 
-    sym_f, sym_x, sym_ones = _get_variables_on(dof_desc.DD_VOLUME)
-    f_volm = actx.to_numpy(flatten(bind(discr, sym.cos(sym_x[0])**2)(actx)))
-    ones_volm = actx.to_numpy(flatten(bind(discr, sym_ones)(actx)))
+    volm_disc = dcoll.discr_from_dd(dof_desc.DD_VOLUME)
+    x_volm = thaw(volm_disc.nodes(), actx)
+    f_volm = f(x_volm)
+    ones_volm = volm_disc.zeros(actx) + 1
 
-    sym_f, sym_x, sym_ones = _get_variables_on(dd_quad)
-    f_quad = bind(discr, sym.cos(sym_x[0])**2)(actx)
-    ones_quad = bind(discr, sym_ones)(actx)
+    quad_disc = dcoll.discr_from_dd(dd_quad)
+    x_quad = thaw(quad_disc.nodes(), actx)
+    f_quad = f(x_quad)
+    ones_quad = quad_disc.zeros(actx) + 1
 
-    mass_op = bind(discr, sym.MassOperator(dd_quad, dof_desc.DD_VOLUME)(sym_f))
+    mop_1 = op.mass(dcoll, dd_quad, f_quad)
+    num_integral_1 = op.nodal_sum(
+        dcoll, dof_desc.DD_VOLUME, ones_volm * mop_1
+    )
 
-    num_integral_1 = np.dot(ones_volm, actx.to_numpy(flatten(mass_op(f=f_quad))))
     err_1 = abs(num_integral_1 - true_integral)
-    assert err_1 < 1e-9, err_1
+    assert err_1 < 2e-9, err_1
+
+    mop_2 = op.mass(dcoll, dd_quad, ones_quad)
+    num_integral_2 = op.nodal_sum(dcoll, dof_desc.DD_VOLUME, f_volm * mop_2)
 
-    num_integral_2 = np.dot(f_volm, actx.to_numpy(flatten(mass_op(f=ones_quad))))
     err_2 = abs(num_integral_2 - true_integral)
-    assert err_2 < 1.0e-9, err_2
+    assert err_2 < 2e-9, err_2
 
     if discr_tag is dof_desc.DISCR_TAG_BASE:
         # NOTE: `integral` always makes a square mass matrix and
         # `QuadratureSimplexGroupFactory` does not have a `mass_matrix` method.
-        num_integral_3 = bind(discr,
-                sym.integral(sym_f, dd=dd_quad))(f=f_quad)
+        num_integral_3 = op.nodal_sum(dcoll, dof_desc.DD_VOLUME, f_quad * mop_2)
         err_3 = abs(num_integral_3 - true_integral)
-        assert err_3 < 5.0e-10, err_3
+        assert err_3 < 5e-10, err_3
 
 # }}}
 
 
-# {{{ mass operator surface area
+# {{{ mass operator on surface
 
 def _ellipse_surface_area(radius, aspect_ratio):
     # https://docs.scipy.org/doc/scipy/reference/generated/scipy.special.ellipe.html
@@ -226,8 +231,8 @@ def test_mass_surface_area(actx_factory, name):
 
     for resolution in builder.resolutions:
         mesh = builder.get_mesh(resolution, builder.mesh_order)
-        discr = DiscretizationCollection(actx, mesh, order=builder.order)
-        volume_discr = discr.discr_from_dd(dof_desc.DD_VOLUME)
+        dcoll = DiscretizationCollection(actx, mesh, order=builder.order)
+        volume_discr = dcoll.discr_from_dd(dof_desc.DD_VOLUME)
 
         logger.info("ndofs:     %d", volume_discr.ndofs)
         logger.info("nelements: %d", volume_discr.mesh.nelements)
@@ -235,8 +240,8 @@ def test_mass_surface_area(actx_factory, name):
         # {{{ compute surface area
 
         dd = dof_desc.DD_VOLUME
-        sym_op = sym.NodalSum(dd)(sym.MassOperator(dd, dd)(sym.Ones(dd)))
-        approx_surface_area = bind(discr, sym_op)(actx)
+        ones_volm = volume_discr.zeros(actx) + 1
+        approx_surface_area = op.integral(dcoll, dd, ones_volm)
 
         logger.info("surface: got {:.5e} / expected {:.5e}".format(
             approx_surface_area, surface_area))
@@ -244,21 +249,21 @@ def test_mass_surface_area(actx_factory, name):
 
         # }}}
 
-        h_max = bind(discr, sym.h_max_from_volume(
-            discr.ambient_dim, dim=discr.dim, dd=dd))(actx)
-        eoc.add_data_point(h_max, area_error + 1.0e-16)
+        # compute max element size
+        h_max = op.h_max_from_volume(dcoll)
+
+        eoc.add_data_point(h_max, area_error)
 
     # }}}
 
     logger.info("surface area error\n%s", str(eoc))
 
-    assert eoc.max_error() < 1.0e-14 \
-            or eoc.order_estimate() > builder.order
+    assert eoc.max_error() < 3e-13 or eoc.order_estimate() > builder.order
 
 # }}}
 
 
-# {{{ surface mass inverse
+# {{{ mass inverse on surfaces
 
 @pytest.mark.parametrize("name", ["2-1-ellipse", "spheroid"])
 def test_surface_mass_operator_inverse(actx_factory, name):
@@ -284,30 +289,31 @@ def test_surface_mass_operator_inverse(actx_factory, name):
 
     for resolution in builder.resolutions:
         mesh = builder.get_mesh(resolution, builder.mesh_order)
-        discr = DiscretizationCollection(actx, mesh, order=builder.order)
-        volume_discr = discr.discr_from_dd(dof_desc.DD_VOLUME)
+        dcoll = DiscretizationCollection(actx, mesh, order=builder.order)
+        volume_discr = dcoll.discr_from_dd(dof_desc.DD_VOLUME)
 
         logger.info("ndofs:     %d", volume_discr.ndofs)
         logger.info("nelements: %d", volume_discr.mesh.nelements)
 
         # {{{ compute inverse mass
 
-        dd = dof_desc.DD_VOLUME
-        sym_f = sym.cos(4.0 * sym.nodes(mesh.ambient_dim, dd)[0])
-        sym_op = sym.InverseMassOperator(dd, dd)(
-                sym.MassOperator(dd, dd)(sym.var("f")))
+        def f(x):
+            return actx.np.cos(4.0 * x[0])
 
-        f = bind(discr, sym_f)(actx)
-        f_inv = bind(discr, sym_op)(actx, f=f)
+        dd = dof_desc.DD_VOLUME
+        x_volm = thaw(volume_discr.nodes(), actx)
+        f_volm = f(x_volm)
+        f_inv = op.inverse_mass(
+            dcoll, op.mass(dcoll, dd, f_volm)
+        )
 
-        inv_error = bind(discr,
-                sym.norm(2, sym.var("x") - sym.var("y"))
-                / sym.norm(2, sym.var("y")))(actx, x=f_inv, y=f)
+        inv_error = op.norm(dcoll, f_volm - f_inv, 2) / op.norm(dcoll, f_volm, 2)
 
         # }}}
 
-        h_max = bind(discr, sym.h_max_from_volume(
-            discr.ambient_dim, dim=discr.dim, dd=dd))(actx)
+        # compute max element size
+        h_max = op.h_max_from_volume(dcoll)
+
         eoc.add_data_point(h_max, inv_error)
 
     # }}}
@@ -340,51 +346,48 @@ def test_face_normal_surface(actx_factory, mesh_name):
         raise ValueError("unknown mesh name: %s" % mesh_name)
 
     mesh = builder.get_mesh(builder.resolutions[0], builder.mesh_order)
-    discr = DiscretizationCollection(actx, mesh, order=builder.order)
+    dcoll = DiscretizationCollection(actx, mesh, order=builder.order)
 
-    volume_discr = discr.discr_from_dd(dof_desc.DD_VOLUME)
+    volume_discr = dcoll.discr_from_dd(dof_desc.DD_VOLUME)
     logger.info("ndofs:    %d", volume_discr.ndofs)
     logger.info("nelements: %d", volume_discr.mesh.nelements)
 
     # }}}
 
-    # {{{ symbolic
+    # {{{ Compute surface and face normals
     from meshmode.discretization.connection import FACE_RESTR_INTERIOR
+    from grudge.geometry import normal
 
     dv = dof_desc.DD_VOLUME
     df = dof_desc.as_dofdesc(FACE_RESTR_INTERIOR)
 
     ambient_dim = mesh.ambient_dim
-    dim = mesh.dim
 
-    sym_surf_normal = sym.project(dv, df)(
-            sym.surface_normal(ambient_dim, dim=dim, dd=dv).as_vector()
-            )
-    sym_surf_normal = sym_surf_normal / sym.sqrt(sum(sym_surf_normal**2))
+    surf_normal = op.project(
+        dcoll, dv, df,
+        normal(actx, dcoll, dd=dv)
+    )
+    surf_normal = surf_normal / actx.np.sqrt(sum(surf_normal**2))
 
-    sym_face_normal_i = sym.normal(df, ambient_dim, dim=dim - 1)
-    sym_face_normal_e = sym.OppositeInteriorFaceSwap(df)(sym_face_normal_i)
+    face_normal_i = thaw(op.normal(dcoll, df), actx)
+    face_normal_e = dcoll.opposite_face_connection()(face_normal_i)
 
     if mesh.ambient_dim == 3:
+        from grudge.geometry import pseudoscalar, area_element
         # NOTE: there's only one face tangent in 3d
-        sym_face_tangent = (
-                sym.pseudoscalar(ambient_dim, dim - 1, dd=df)
-                / sym.area_element(ambient_dim, dim - 1, dd=df)).as_vector()
+        face_tangent = (
+            pseudoscalar(actx, dcoll, dd=df) / area_element(actx, dcoll, dd=df)
+        ).as_vector(dtype=object)
 
     # }}}
 
     # {{{ checks
 
     def _eval_error(x):
-        return bind(discr, sym.norm(np.inf, sym.var("x", dd=df), dd=df))(actx, x=x)
+        return op.norm(dcoll, x, np.inf, dd=df)
 
     rtol = 1.0e-14
 
-    surf_normal = bind(discr, sym_surf_normal)(actx)
-
-    face_normal_i = bind(discr, sym_face_normal_i)(actx)
-    face_normal_e = bind(discr, sym_face_normal_e)(actx)
-
     # check interpolated surface normal is orthogonal to face normal
     error = _eval_error(surf_normal.dot(face_normal_i))
     logger.info("error[n_dot_i]:    %.5e", error)
@@ -397,8 +400,6 @@ def test_face_normal_surface(actx_factory, mesh_name):
 
     # check orthogonality with face tangent
     if ambient_dim == 3:
-        face_tangent = bind(discr, sym_face_tangent)(actx)
-
         error = _eval_error(face_tangent.dot(face_normal_i))
         logger.info("error[t_dot_i]:  %.5e", error)
         assert error < 5 * rtol
@@ -422,24 +423,25 @@ def test_tri_diff_mat(actx_factory, dim, order=4):
     from pytools.convergence import EOCRecorder
     axis_eoc_recs = [EOCRecorder() for axis in range(dim)]
 
+    def f(x, axis):
+        return actx.np.sin(3*x[axis])
+
+    def df(x, axis):
+        return 3*actx.np.cos(3*x[axis])
+
     for n in [4, 8, 16]:
         mesh = mgen.generate_regular_rect_mesh(a=(-0.5,)*dim, b=(0.5,)*dim,
                 nelements_per_axis=(n,)*dim, order=4)
 
-        discr = DiscretizationCollection(actx, mesh, order=4)
-        nabla = sym.nabla(dim)
+        dcoll = DiscretizationCollection(actx, mesh, order=4)
+        volume_discr = dcoll.discr_from_dd(dof_desc.DD_VOLUME)
+        x = thaw(volume_discr.nodes(), actx)
 
         for axis in range(dim):
-            x = sym.nodes(dim)
-
-            f = bind(discr, sym.sin(3*x[axis]))(actx)
-            df = bind(discr, 3*sym.cos(3*x[axis]))(actx)
+            df_num = op.local_grad(dcoll, f(x, axis))[axis]
+            df_volm = df(x, axis)
 
-            sym_op = nabla[axis](sym.var("f"))
-            bound_op = bind(discr, sym_op)
-            df_num = bound_op(f=f)
-
-            linf_error = actx.np.linalg.norm(df_num - df, ord=np.inf)
+            linf_error = flat_norm(df_num - df_volm, ord=np.inf)
             axis_eoc_recs[axis].add_data_point(1/n, linf_error)
 
     for axis, eoc_rec in enumerate(axis_eoc_recs):
@@ -473,25 +475,24 @@ def test_2d_gauss_theorem(actx_factory):
 
     actx = actx_factory()
 
-    discr = DiscretizationCollection(actx, mesh, order=2)
+    dcoll = DiscretizationCollection(actx, mesh, order=2)
+    volm_disc = dcoll.discr_from_dd(dof_desc.DD_VOLUME)
+    x_volm = thaw(volm_disc.nodes(), actx)
 
     def f(x):
         return flat_obj_array(
-                sym.sin(3*x[0])+sym.cos(3*x[1]),
-                sym.sin(2*x[0])+sym.cos(x[1]))
+            actx.np.sin(3*x[0]) + actx.np.cos(3*x[1]),
+            actx.np.sin(2*x[0]) + actx.np.cos(x[1])
+        )
 
-    gauss_err = bind(discr,
-            sym.integral((
-                sym.nabla(2) * f(sym.nodes(2))
-                ).sum())
-            -  # noqa: W504
-            sym.integral(
-                sym.project("vol", BTAG_ALL)(f(sym.nodes(2)))
-                .dot(sym.normal(BTAG_ALL, 2)),
-                dd=BTAG_ALL)
-            )(actx)
+    f_volm = f(x_volm)
+    int_1 = op.integral(dcoll, "vol", op.local_div(dcoll, f_volm))
 
-    assert abs(gauss_err) < 1e-13
+    prj_f = op.project(dcoll, "vol", BTAG_ALL, f_volm)
+    normal = thaw(op.normal(dcoll, BTAG_ALL), actx)
+    int_2 = op.integral(dcoll, BTAG_ALL, prj_f.dot(normal))
+
+    assert abs(int_1 - int_2) < 1e-13
 
 
 @pytest.mark.parametrize("mesh_name", ["2-1-ellipse", "spheroid"])
@@ -537,10 +538,10 @@ def test_surface_divergence_theorem(actx_factory, mesh_name, visualize=False):
 
     def f(x):
         return flat_obj_array(
-                sym.sin(3*x[1]) + sym.cos(3*x[0]) + 1.0,
-                sym.sin(2*x[0]) + sym.cos(x[1]),
-                3.0 * sym.cos(x[0] / 2) + sym.cos(x[1]),
-                )[:ambient_dim]
+            actx.np.sin(3*x[1]) + actx.np.cos(3*x[0]) + 1.0,
+            actx.np.sin(2*x[0]) + actx.np.cos(x[1]),
+            3.0 * actx.np.cos(x[0] / 2) + actx.np.cos(x[1]),
+        )[:ambient_dim]
 
     from pytools.convergence import EOCRecorder
     eoc_global = EOCRecorder()
@@ -571,65 +572,60 @@ def test_surface_divergence_theorem(actx_factory, mesh_name, visualize=False):
 
         from meshmode.discretization.poly_element import \
                 QuadratureSimplexGroupFactory
-        discr = DiscretizationCollection(
+
+        qtag = dof_desc.DISCR_TAG_QUAD
+        dcoll = DiscretizationCollection(
             actx, mesh, order=builder.order,
             discr_tag_to_group_factory={
-                    "product": QuadratureSimplexGroupFactory(2 * builder.order)
+                qtag: QuadratureSimplexGroupFactory(2 * builder.order)
             }
         )
 
-        volume = discr.discr_from_dd(dof_desc.DD_VOLUME)
+        volume = dcoll.discr_from_dd(dof_desc.DD_VOLUME)
         logger.info("ndofs:     %d", volume.ndofs)
         logger.info("nelements: %d", volume.mesh.nelements)
 
         dd = dof_desc.DD_VOLUME
-        dq = dd.with_discr_tag("product")
+        dq = dd.with_discr_tag(qtag)
         df = dof_desc.as_dofdesc(FACE_RESTR_ALL)
-        ambient_dim = discr.ambient_dim
-        dim = discr.dim
+        ambient_dim = dcoll.ambient_dim
 
         # variables
-        sym_f = f(sym.nodes(ambient_dim, dd=dd))
-        sym_f_quad = f(sym.nodes(ambient_dim, dd=dq))
-        sym_kappa = sym.summed_curvature(ambient_dim, dim=dim, dd=dq)
-        sym_normal = sym.surface_normal(ambient_dim, dim=dim, dd=dq).as_vector()
+        f_num = f(thaw(op.nodes(dcoll, dd=dd), actx))
+        f_quad_num = f(thaw(op.nodes(dcoll, dd=dq), actx))
 
-        sym_face_normal = sym.normal(df, ambient_dim, dim=dim - 1)
-        sym_face_f = sym.project(dd, df)(sym_f)
+        from grudge.geometry import normal, summed_curvature
+
+        kappa = summed_curvature(actx, dcoll, dd=dq)
+        normal = normal(actx, dcoll, dd=dq)
+        face_normal = thaw(op.normal(dcoll, df), actx)
+        face_f = op.project(dcoll, dd, df, f_num)
 
         # operators
-        sym_stiff = sum(
-                sym.StiffnessOperator(d)(f) for d, f in enumerate(sym_f)
-                )
-        sym_stiff_t = sum(
-                sym.StiffnessTOperator(d)(f) for d, f in enumerate(sym_f)
-                )
-        sym_k = sym.MassOperator(dq, dd)(sym_kappa * sym_f_quad.dot(sym_normal))
-        sym_flux = sym.FaceMassOperator()(sym_face_f.dot(sym_face_normal))
+        stiff = op.mass(dcoll, sum(op.local_d_dx(dcoll, i, f_num_i)
+                                   for i, f_num_i in enumerate(f_num)))
+        stiff_t = sum(op.weak_local_d_dx(dcoll, i, f_num_i)
+                      for i, f_num_i in enumerate(f_num))
+        kterm = op.mass(dcoll, dq, kappa * f_quad_num.dot(normal))
+        flux = op.face_mass(dcoll, face_f.dot(face_normal))
 
         # sum everything up
-        sym_op_global = sym.NodalSum(dd)(
-                sym_stiff - (sym_stiff_t + sym_k))
-        sym_op_local = sym.ElementwiseSumOperator(dd)(
-                sym_stiff - (sym_stiff_t + sym_k + sym_flux))
-
-        # evaluate
-        op_global = bind(discr, sym_op_global)(actx)
-        op_local = bind(discr, sym_op_local)(actx)
+        op_global = op.nodal_sum(dcoll, dd, stiff - (stiff_t + kterm))
+        op_local = op.elementwise_sum(dcoll, dd, stiff - (stiff_t + kterm + flux))
 
         err_global = abs(op_global)
-        err_local = bind(discr, sym.norm(np.inf, sym.var("x")))(actx, x=op_local)
+        err_local = op.norm(dcoll, op_local, np.inf)
         logger.info("errors: global %.5e local %.5e", err_global, err_local)
 
         # compute max element size
-        h_max = bind(discr, sym.h_max_from_volume(
-            discr.ambient_dim, dim=discr.dim, dd=dd))(actx)
+        h_max = op.h_max_from_volume(dcoll)
+
         eoc_global.add_data_point(h_max, err_global)
         eoc_local.add_data_point(h_max, err_local)
 
         if visualize:
             from grudge.shortcuts import make_visualizer
-            vis = make_visualizer(discr, vis_order=builder.order)
+            vis = make_visualizer(dcoll)
 
             filename = f"surface_divergence_theorem_{mesh_name}_{i:04d}.vtu"
             vis.write_vtk_file(filename, [
@@ -727,39 +723,38 @@ def test_convergence_advec(actx_factory, mesh_name, mesh_pars, op_type, flux_typ
         norm_v = la.norm(v)
 
         def f(x):
-            return sym.sin(10*x)
+            return actx.np.sin(10*x)
 
-        def u_analytic(x):
-            return f(
-                    -v.dot(x)/norm_v
-                    + sym.var("t", dof_desc.DD_SCALAR)*norm_v)
+        def u_analytic(x, t=0):
+            return f(-v.dot(x)/norm_v + t*norm_v)
 
         from grudge.models.advection import (
-                StrongAdvectionOperator, WeakAdvectionOperator)
+            StrongAdvectionOperator, WeakAdvectionOperator
+        )
         from meshmode.mesh import BTAG_ALL
 
-        discr = DiscretizationCollection(actx, mesh, order=order)
-        op_class = {
-                "strong": StrongAdvectionOperator,
-                "weak": WeakAdvectionOperator,
-                }[op_type]
-        op = op_class(v,
-                inflow_u=u_analytic(sym.nodes(dim, BTAG_ALL)),
-                flux_type=flux_type)
+        dcoll = DiscretizationCollection(actx, mesh, order=order)
+        op_class = {"strong": StrongAdvectionOperator,
+                    "weak": WeakAdvectionOperator}[op_type]
+        adv_operator = op_class(dcoll, v,
+                                inflow_u=lambda t: u_analytic(
+                                    thaw(op.nodes(dcoll, dd=BTAG_ALL), actx),
+                                    t=t
+                                ),
+                                flux_type=flux_type)
 
-        bound_op = bind(discr, op.sym_operator())
-
-        u = bind(discr, u_analytic(sym.nodes(dim)))(actx, t=0)
+        nodes = thaw(op.nodes(dcoll), actx)
+        u = u_analytic(nodes, t=0)
 
         def rhs(t, u):
-            return bound_op(t=t, u=u)
+            return adv_operator.operator(t, u)
 
         if dim == 3:
             final_time = 0.1
         else:
             final_time = 0.2
 
-        h_max = bind(discr, sym.h_max_from_volume(discr.ambient_dim))(actx)
+        h_max = op.h_max_from_volume(dcoll, dim=dcoll.ambient_dim)
         dt = dt_factor * h_max/order**2
         nsteps = (final_time // dt) + 1
         dt = final_time/nsteps + 1e-15
@@ -770,7 +765,7 @@ def test_convergence_advec(actx_factory, mesh_name, mesh_pars, op_type, flux_typ
         last_u = None
 
         from grudge.shortcuts import make_visualizer
-        vis = make_visualizer(discr, vis_order=order)
+        vis = make_visualizer(dcoll)
 
         step = 0
 
@@ -783,12 +778,16 @@ def test_convergence_advec(actx_factory, mesh_name, mesh_pars, op_type, flux_typ
                 last_u = event.state_component
 
                 if visualize:
-                    vis.write_vtk_file("fld-%s-%04d.vtu" % (mesh_par, step),
-                            [("u", event.state_component)])
-
-        error_l2 = bind(discr,
-            sym.norm(2, sym.var("u")-u_analytic(sym.nodes(dim))))(
-                t=last_t, u=last_u)
+                    vis.write_vtk_file(
+                        "fld-%s-%04d.vtu" % (mesh_par, step),
+                        [("u", event.state_component)]
+                    )
+
+        error_l2 = op.norm(
+            dcoll,
+            last_u - u_analytic(nodes, t=last_t),
+            2
+        )
         logger.info("h_max %.5e error %.5e", h_max, error_l2)
         eoc_rec.add_data_point(h_max, error_l2)
 
@@ -824,24 +823,32 @@ def test_convergence_maxwell(actx_factory,  order):
                 b=(1.0,)*dims,
                 nelements_per_axis=(n,)*dims)
 
-        discr = DiscretizationCollection(actx, mesh, order=order)
+        dcoll = DiscretizationCollection(actx, mesh, order=order)
 
         epsilon = 1
         mu = 1
 
         from grudge.models.em import get_rectangular_cavity_mode
-        sym_mode = get_rectangular_cavity_mode(1, (1, 2, 2))
 
-        analytic_sol = bind(discr, sym_mode)
-        fields = analytic_sol(actx, t=0, epsilon=epsilon, mu=mu)
+        def analytic_sol(x, t=0):
+            return get_rectangular_cavity_mode(actx, x, t, 1, (1, 2, 2))
+
+        nodes = thaw(op.nodes(dcoll), actx)
+        fields = analytic_sol(nodes, t=0)
 
         from grudge.models.em import MaxwellOperator
-        op = MaxwellOperator(epsilon, mu, flux_type=0.5, dimensions=dims)
-        op.check_bc_coverage(mesh)
-        bound_op = bind(discr, op.sym_operator())
+
+        maxwell_operator = MaxwellOperator(
+            dcoll,
+            epsilon,
+            mu,
+            flux_type=0.5,
+            dimensions=dims
+        )
+        maxwell_operator.check_bc_coverage(mesh)
 
         def rhs(t, w):
-            return bound_op(t=t, w=w)
+            return maxwell_operator.operator(t, w)
 
         dt = 0.002
         final_t = dt * 5
@@ -852,8 +859,6 @@ def test_convergence_maxwell(actx_factory,  order):
 
         logger.info("dt %.5e nsteps %5d", dt, nsteps)
 
-        norm = bind(discr, sym.norm(2, sym.var("u")))
-
         step = 0
         for event in dt_stepper.run(t_end=final_t):
             if isinstance(event, dt_stepper.StateComputed):
@@ -863,9 +868,8 @@ def test_convergence_maxwell(actx_factory,  order):
                 step += 1
                 logger.debug("[%04d] t = %.5e", step, event.t)
 
-        sol = analytic_sol(actx, mu=mu, epsilon=epsilon, t=step * dt)
-        vals = [norm(u=(esc[i] - sol[i])) / norm(u=sol[i]) for i in range(5)] # noqa E501
-        total_error = sum(vals)
+        sol = analytic_sol(nodes, t=step * dt)
+        total_error = op.norm(dcoll, esc - sol, 2)
         eoc_rec.add_data_point(1.0/n, total_error)
 
     logger.info("\n%s", eoc_rec.pretty_print(
@@ -885,20 +889,15 @@ def test_improvement_quadrature(actx_factory, order):
     from grudge.models.advection import VariableCoefficientAdvectionOperator
     from pytools.convergence import EOCRecorder
     from meshmode.discretization.poly_element import QuadratureSimplexGroupFactory
+    from meshmode.mesh import BTAG_ALL
 
     actx = actx_factory()
 
     dims = 2
-    sym_nds = sym.nodes(dims)
-    advec_v = flat_obj_array(-1*sym_nds[1], sym_nds[0])
-
-    flux = "upwind"
-    op = VariableCoefficientAdvectionOperator(advec_v, 0, flux_type=flux)
 
-    def gaussian_mode():
+    def gaussian_mode(x):
         source_width = 0.1
-        sym_x = sym.nodes(2)
-        return sym.exp(-np.dot(sym_x, sym_x) / source_width**2)
+        return actx.np.exp(-np.dot(x, x) / source_width**2)
 
     def conv_test(descr, use_quad):
         logger.info("-" * 75)
@@ -906,6 +905,11 @@ def test_improvement_quadrature(actx_factory, order):
         logger.info("-" * 75)
         eoc_rec = EOCRecorder()
 
+        if use_quad:
+            qtag = dof_desc.DISCR_TAG_QUAD
+        else:
+            qtag = None
+
         ns = [20, 25]
         for n in ns:
             mesh = mgen.generate_regular_rect_mesh(
@@ -916,22 +920,33 @@ def test_improvement_quadrature(actx_factory, order):
 
             if use_quad:
                 discr_tag_to_group_factory = {
-                    "product": QuadratureSimplexGroupFactory(order=4*order)
+                    qtag: QuadratureSimplexGroupFactory(order=4*order)
                 }
             else:
-                discr_tag_to_group_factory = {"product": None}
+                discr_tag_to_group_factory = {}
 
-            discr = DiscretizationCollection(
+            dcoll = DiscretizationCollection(
                 actx, mesh, order=order,
                 discr_tag_to_group_factory=discr_tag_to_group_factory
             )
 
-            bound_op = bind(discr, op.sym_operator())
-            fields = bind(discr, gaussian_mode())(actx, t=0)
-            norm = bind(discr, sym.norm(2, sym.var("u")))
+            nodes = thaw(op.nodes(dcoll), actx)
 
-            esc = bound_op(u=fields)
-            total_error = norm(u=esc)
+            def zero_inflow(dtag, t=0):
+                dd = dof_desc.DOFDesc(dtag, qtag)
+                return dcoll.discr_from_dd(dd).zeros(actx)
+
+            adv_op = VariableCoefficientAdvectionOperator(
+                dcoll,
+                flat_obj_array(-1*nodes[1], nodes[0]),
+                inflow_u=lambda t: zero_inflow(BTAG_ALL, t=t),
+                flux_type="upwind",
+                quad_tag=qtag
+            )
+
+            total_error = op.norm(
+                dcoll, adv_op.operator(0, gaussian_mode(nodes)), 2
+            )
             eoc_rec.add_data_point(1.0/n, total_error)
 
         logger.info("\n%s", eoc_rec.pretty_print(
@@ -950,35 +965,6 @@ def test_improvement_quadrature(actx_factory, order):
 # }}}
 
 
-# {{{ operator collector determinism
-
-def test_op_collector_order_determinism():
-    class TestOperator(sym.Operator):
-
-        def __init__(self):
-            sym.Operator.__init__(self, dof_desc.DD_VOLUME, dof_desc.DD_VOLUME)
-
-        mapper_method = "map_test_operator"
-
-    from grudge.symbolic.mappers import BoundOperatorCollector
-
-    class TestBoundOperatorCollector(BoundOperatorCollector):
-
-        def map_test_operator(self, expr):
-            return self.map_operator(expr)
-
-    v0 = sym.var("v0")
-    ob0 = sym.OperatorBinding(TestOperator(), v0)
-
-    v1 = sym.var("v1")
-    ob1 = sym.OperatorBinding(TestOperator(), v1)
-
-    # The output order isn't significant, but it should always be the same.
-    assert list(TestBoundOperatorCollector(TestOperator)(ob0 + ob1)) == [ob0, ob1]
-
-# }}}
-
-
 # {{{ bessel
 
 def test_bessel(actx_factory):
@@ -991,90 +977,33 @@ def test_bessel(actx_factory):
             b=(1.0,)*dims,
             nelements_per_axis=(8,)*dims)
 
-    discr = DiscretizationCollection(actx, mesh, order=3)
+    dcoll = DiscretizationCollection(actx, mesh, order=3)
+
+    nodes = thaw(op.nodes(dcoll), actx)
+    r = actx.np.sqrt(nodes[0]**2 + nodes[1]**2)
 
-    nodes = sym.nodes(dims)
-    r = sym.cse(sym.sqrt(nodes[0]**2 + nodes[1]**2))
+    # FIXME: Bessel functions need to brought out of the symbolic
+    # layer. Related issue: https://github.com/inducer/grudge/issues/93
+    def bessel_j(actx, n, r):
+        from grudge import sym, bind
+        return bind(dcoll, sym.bessel_j(n, sym.var("r")))(actx, r=r)
 
     # https://dlmf.nist.gov/10.6.1
     n = 3
-    bessel_zero = (
-            sym.bessel_j(n+1, r)
-            + sym.bessel_j(n-1, r)
-            - 2*n/r * sym.bessel_j(n, r))
+    bessel_zero = (bessel_j(actx, n+1, r)
+                   + bessel_j(actx, n-1, r)
+                   - 2*n/r * bessel_j(actx, n, r))
 
-    z = bind(discr, sym.norm(2, bessel_zero))(actx)
+    z = op.norm(dcoll, bessel_zero, 2)
 
     assert z < 1e-15
 
 # }}}
 
 
-# {{{ function symbol
-
-def test_external_call(actx_factory):
-    actx = actx_factory()
-
-    def double(queue, x):
-        return 2 * x
-
-    dims = 2
-
-    mesh = mgen.generate_regular_rect_mesh(
-            a=(0,) * dims, b=(1,) * dims, nelements_per_axis=(4,) * dims)
-    discr = DiscretizationCollection(actx, mesh, order=1)
-
-    ones = sym.Ones(dof_desc.DD_VOLUME)
-    op = (
-            ones * 3
-            + sym.FunctionSymbol("double")(ones))
-
-    from grudge.function_registry import (
-            base_function_registry, register_external_function)
-
-    freg = register_external_function(
-            base_function_registry,
-            "double",
-            implementation=double,
-            dd=dof_desc.DD_VOLUME)
-
-    bound_op = bind(discr, op, function_registry=freg)
-
-    result = bound_op(actx, double=double)
-    assert actx.to_numpy(flatten(result) == 5).all()
-
-
-@pytest.mark.parametrize("array_type", ["scalar", "vector"])
-def test_function_symbol_array(actx_factory, array_type):
-    """Test if `FunctionSymbol` distributed properly over object arrays."""
-
-    actx = actx_factory()
-
-    dim = 2
-    mesh = mgen.generate_regular_rect_mesh(
-            a=(-0.5,)*dim, b=(0.5,)*dim,
-            nelements_per_axis=(8,)*dim, order=4)
-    discr = DiscretizationCollection(actx, mesh, order=4)
-    volume_discr = discr.discr_from_dd(dof_desc.DD_VOLUME)
-
-    if array_type == "scalar":
-        sym_x = sym.var("x")
-        x = thaw(actx, actx.np.cos(volume_discr.nodes()[0]))
-    elif array_type == "vector":
-        sym_x = sym.make_sym_array("x", dim)
-        x = thaw(actx, volume_discr.nodes())
-    else:
-        raise ValueError("unknown array type")
-
-    norm = bind(discr, sym.norm(2, sym_x))(x=x)
-    assert isinstance(norm, float)
-
-# }}}
-
-
 @pytest.mark.parametrize("p", [2, np.inf])
 def test_norm_obj_array(actx_factory, p):
-    """Test :func:`grudge.symbolic.operators.norm` for object arrays."""
+    """Test :func:`grudge.op.norm` for object arrays."""
 
     actx = actx_factory()
 
@@ -1082,14 +1011,13 @@ def test_norm_obj_array(actx_factory, p):
     mesh = mgen.generate_regular_rect_mesh(
             a=(-0.5,)*dim, b=(0.5,)*dim,
             nelements_per_axis=(8,)*dim, order=1)
-    discr = DiscretizationCollection(actx, mesh, order=4)
+    dcoll = DiscretizationCollection(actx, mesh, order=4)
 
     w = make_obj_array([1.0, 2.0, 3.0])[:dim]
 
     # {{ scalar
 
-    sym_w = sym.var("w")
-    norm = bind(discr, sym.norm(p, sym_w))(actx, w=w[0])
+    norm = op.norm(dcoll, w[0], p)
 
     norm_exact = w[0]
     logger.info("norm: %.5e %.5e", norm, norm_exact)
@@ -1099,8 +1027,7 @@ def test_norm_obj_array(actx_factory, p):
 
     # {{{ vector
 
-    sym_w = sym.make_sym_array("w", dim)
-    norm = bind(discr, sym.norm(p, sym_w))(actx, w=w)
+    norm = op.norm(dcoll, w, p)
 
     norm_exact = np.sqrt(np.sum(w**2)) if p == 2 else np.max(w)
     logger.info("norm: %.5e %.5e", norm, norm_exact)
@@ -1109,23 +1036,6 @@ def test_norm_obj_array(actx_factory, p):
     # }}}
 
 
-def test_map_if(actx_factory):
-    """Test :meth:`grudge.symbolic.execution.ExecutionMapper.map_if` handling
-    of scalar conditions.
-    """
-
-    actx = actx_factory()
-
-    dim = 2
-    mesh = mgen.generate_regular_rect_mesh(
-            a=(-0.5,)*dim, b=(0.5,)*dim,
-            nelements_per_axis=(8,)*dim, order=4)
-    discr = DiscretizationCollection(actx, mesh, order=4)
-
-    sym_if = sym.If(sym.Comparison(2.0, "<", 1.0e-14), 1.0, 2.0)
-    bind(discr, sym_if)(actx)
-
-
 def test_empty_boundary(actx_factory):
     # https://github.com/inducer/grudge/issues/54
 
@@ -1137,96 +1047,12 @@ def test_empty_boundary(actx_factory):
     mesh = mgen.generate_regular_rect_mesh(
             a=(-0.5,)*dim, b=(0.5,)*dim,
             nelements_per_axis=(8,)*dim, order=4)
-    discr = DiscretizationCollection(actx, mesh, order=4)
-    normal = bind(discr,
-            sym.normal(BTAG_NONE, dim, dim=dim - 1))(actx)
+    dcoll = DiscretizationCollection(actx, mesh, order=4)
+    normal = op.normal(dcoll, BTAG_NONE)
     from meshmode.dof_array import DOFArray
     for component in normal:
         assert isinstance(component, DOFArray)
-        assert len(component) == len(discr.discr_from_dd(BTAG_NONE).groups)
-
-
-def test_operator_compiler_overwrite(actx_factory):
-    """Tests that the same expression in ``eval_code`` and ``discr_code``
-    does not confuse the OperatorCompiler in grudge/symbolic/compiler.py.
-    """
-
-    actx = actx_factory()
-
-    ambient_dim = 2
-    target_order = 4
-
-    from meshmode.mesh.generation import generate_regular_rect_mesh
-    mesh = generate_regular_rect_mesh(
-            a=(-0.5,)*ambient_dim, b=(0.5,)*ambient_dim,
-            n=(8,)*ambient_dim, order=1)
-    discr = DiscretizationCollection(actx, mesh, order=target_order)
-
-    # {{{ test
-
-    sym_u = sym.nodes(ambient_dim)
-    sym_div_u = sum(d(u) for d, u in zip(sym.nabla(ambient_dim), sym_u))
-
-    div_u = bind(discr, sym_div_u)(actx)
-    error = bind(discr, sym.norm(2, sym.var("x")))(actx, x=div_u - discr.dim)
-    logger.info("error: %.5e", error)
-
-    # }}}
-
-
-@pytest.mark.parametrize("ambient_dim", [
-    2,
-    # FIXME, cf. https://github.com/inducer/grudge/pull/78/
-    pytest.param(3, marks=pytest.mark.xfail)
-    ])
-def test_incorrect_assignment_aggregation(actx_factory, ambient_dim):
-    """Tests that the greedy assignemnt aggregation code works on a non-trivial
-    expression (on which it didn't work at the time of writing).
-    """
-
-    actx = actx_factory()
-
-    target_order = 4
-
-    from meshmode.mesh.generation import generate_regular_rect_mesh
-    mesh = generate_regular_rect_mesh(
-            a=(-0.5,)*ambient_dim, b=(0.5,)*ambient_dim,
-            n=(8,)*ambient_dim, order=1)
-    discr = DiscretizationCollection(actx, mesh, order=target_order)
-
-    # {{{ test with a relative norm
-
-    from grudge.dof_desc import DD_VOLUME
-    dd = DD_VOLUME
-    sym_x = sym.make_sym_array("y", ambient_dim, dd=dd)
-    sym_y = sym.make_sym_array("y", ambient_dim, dd=dd)
-
-    sym_norm_y = sym.norm(2, sym_y, dd=dd)
-    sym_norm_d = sym.norm(2, sym_x - sym_y, dd=dd)
-    sym_op = sym_norm_d / sym_norm_y
-    logger.info("%s", sym.pretty(sym_op))
-
-    # FIXME: this shouldn't raise a RuntimeError
-    with pytest.raises(RuntimeError):
-        bind(discr, sym_op)(actx, x=1.0, y=discr.discr_from_dd(dd).nodes())
-
-    # }}}
-
-    # {{{ test with repeated mass inverses
-
-    sym_minv_y = sym.cse(sym.InverseMassOperator()(sym_y), "minv_y")
-
-    sym_u = make_obj_array([0.5 * sym.Ones(dd), 0.0, 0.0])[:ambient_dim]
-    sym_div_u = sum(d(u) for d, u in zip(sym.nabla(ambient_dim), sym_u))
-
-    sym_op = sym.MassOperator(dd)(sym_u) \
-            + sym.MassOperator(dd)(sym_minv_y * sym_div_u)
-    logger.info("%s", sym.pretty(sym_op))
-
-    # FIXME: this shouldn't raise a RuntimeError either
-    bind(discr, sym_op)(actx, y=discr.discr_from_dd(dd).nodes())
-
-    # }}}
+        assert len(component) == len(dcoll.discr_from_dd(BTAG_NONE).groups)
 
 
 # You can test individual routines by typing
diff --git a/test/test_grudge_sym_old.py b/test/test_grudge_sym_old.py
new file mode 100644
index 0000000000000000000000000000000000000000..ed47028f08716c51902a2c1b5d6bee7ef261c4b8
--- /dev/null
+++ b/test/test_grudge_sym_old.py
@@ -0,0 +1,942 @@
+__copyright__ = "Copyright (C) 2015 Andreas Kloeckner"
+
+__license__ = """
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+"""
+
+import numpy as np
+
+from meshmode import _acf       # noqa: F401
+from meshmode.dof_array import flatten, thaw
+import meshmode.mesh.generation as mgen
+
+from pytools.obj_array import flat_obj_array, make_obj_array
+
+from grudge import sym, bind, DiscretizationCollection
+
+import grudge.dof_desc as dof_desc
+
+import pytest
+from meshmode.array_context import (  # noqa
+        pytest_generate_tests_for_pyopencl_array_context
+        as pytest_generate_tests)
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+# {{{ inverse metric
+
+@pytest.mark.parametrize("dim", [2, 3])
+def test_inverse_metric(actx_factory, dim):
+    actx = actx_factory()
+
+    mesh = mgen.generate_regular_rect_mesh(a=(-0.5,)*dim, b=(0.5,)*dim,
+            nelements_per_axis=(6,)*dim, order=4)
+
+    def m(x):
+        result = np.empty_like(x)
+        result[0] = (
+                1.5*x[0] + np.cos(x[0])
+                + 0.1*np.sin(10*x[1]))
+        result[1] = (
+                0.05*np.cos(10*x[0])
+                + 1.3*x[1] + np.sin(x[1]))
+        if len(x) == 3:
+            result[2] = x[2]
+        return result
+
+    from meshmode.mesh.processing import map_mesh
+    mesh = map_mesh(mesh, m)
+
+    discr = DiscretizationCollection(actx, mesh, order=4)
+
+    sym_op = (
+            sym.forward_metric_derivative_mat(mesh.dim)
+            .dot(
+                sym.inverse_metric_derivative_mat(mesh.dim)
+                )
+            .reshape(-1))
+
+    op = bind(discr, sym_op)
+    mat = op(actx).reshape(mesh.dim, mesh.dim)
+
+    for i in range(mesh.dim):
+        for j in range(mesh.dim):
+            tgt = 1 if i == j else 0
+
+            err = actx.np.linalg.norm(mat[i, j] - tgt, ord=np.inf)
+            logger.info("error[%d, %d]: %.5e", i, j, err)
+            assert err < 1.0e-12, (i, j, err)
+
+# }}}
+
+
+# {{{ mass operator trig integration
+
+@pytest.mark.parametrize("ambient_dim", [1, 2, 3])
+@pytest.mark.parametrize("discr_tag", [dof_desc.DISCR_TAG_BASE,
+                                       dof_desc.DISCR_TAG_QUAD])
+def test_mass_mat_trig(actx_factory, ambient_dim, discr_tag):
+    """Check the integral of some trig functions on an interval using the mass
+    matrix.
+    """
+    actx = actx_factory()
+
+    nel_1d = 16
+    order = 4
+
+    a = -4.0 * np.pi
+    b = +9.0 * np.pi
+    true_integral = 13*np.pi/2 * (b - a)**(ambient_dim - 1)
+
+    from meshmode.discretization.poly_element import QuadratureSimplexGroupFactory
+    dd_quad = dof_desc.DOFDesc(dof_desc.DTAG_VOLUME_ALL, discr_tag)
+    if discr_tag is dof_desc.DISCR_TAG_BASE:
+        discr_tag_to_group_factory = {}
+    else:
+        discr_tag_to_group_factory = {
+            discr_tag: QuadratureSimplexGroupFactory(order=2*order)
+        }
+
+    mesh = mgen.generate_regular_rect_mesh(
+            a=(a,)*ambient_dim, b=(b,)*ambient_dim,
+            nelements_per_axis=(nel_1d,)*ambient_dim, order=1)
+    discr = DiscretizationCollection(
+        actx, mesh, order=order,
+        discr_tag_to_group_factory=discr_tag_to_group_factory
+    )
+
+    def _get_variables_on(dd):
+        sym_f = sym.var("f", dd=dd)
+        sym_x = sym.nodes(ambient_dim, dd=dd)
+        sym_ones = sym.Ones(dd)
+
+        return sym_f, sym_x, sym_ones
+
+    sym_f, sym_x, sym_ones = _get_variables_on(dof_desc.DD_VOLUME)
+    f_volm = actx.to_numpy(flatten(bind(discr, sym.cos(sym_x[0])**2)(actx)))
+    ones_volm = actx.to_numpy(flatten(bind(discr, sym_ones)(actx)))
+
+    sym_f, sym_x, sym_ones = _get_variables_on(dd_quad)
+    f_quad = bind(discr, sym.cos(sym_x[0])**2)(actx)
+    ones_quad = bind(discr, sym_ones)(actx)
+
+    mass_op = bind(discr, sym.MassOperator(dd_quad, dof_desc.DD_VOLUME)(sym_f))
+
+    num_integral_1 = np.dot(ones_volm, actx.to_numpy(flatten(mass_op(f=f_quad))))
+    err_1 = abs(num_integral_1 - true_integral)
+    assert err_1 < 1e-9, err_1
+
+    num_integral_2 = np.dot(f_volm, actx.to_numpy(flatten(mass_op(f=ones_quad))))
+    err_2 = abs(num_integral_2 - true_integral)
+    assert err_2 < 1.0e-9, err_2
+
+    if discr_tag is dof_desc.DISCR_TAG_BASE:
+        # NOTE: `integral` always makes a square mass matrix and
+        # `QuadratureSimplexGroupFactory` does not have a `mass_matrix` method.
+        num_integral_3 = bind(discr,
+                sym.integral(sym_f, dd=dd_quad))(f=f_quad)
+        err_3 = abs(num_integral_3 - true_integral)
+        assert err_3 < 5.0e-10, err_3
+
+# }}}
+
+
+# {{{ mass operator surface area
+
+def _ellipse_surface_area(radius, aspect_ratio):
+    # https://docs.scipy.org/doc/scipy/reference/generated/scipy.special.ellipe.html
+    eccentricity = 1.0 - (1/aspect_ratio)**2
+
+    if abs(aspect_ratio - 2.0) < 1.0e-14:
+        # NOTE: hardcoded value so we don't need scipy for the test
+        ellip_e = 1.2110560275684594
+    else:
+        from scipy.special import ellipe        # pylint: disable=no-name-in-module
+        ellip_e = ellipe(eccentricity)
+
+    return 4.0 * radius * ellip_e
+
+
+def _spheroid_surface_area(radius, aspect_ratio):
+    # https://en.wikipedia.org/wiki/Ellipsoid#Surface_area
+    a = 1.0
+    c = aspect_ratio
+
+    if a < c:
+        e = np.sqrt(1.0 - (a/c)**2)
+        return 2.0 * np.pi * radius**2 * (1.0 + (c/a) / e * np.arcsin(e))
+    else:
+        e = np.sqrt(1.0 - (c/a)**2)
+        return 2.0 * np.pi * radius**2 * (1 + (c/a)**2 / e * np.arctanh(e))
+
+
+@pytest.mark.parametrize("name", [
+    "2-1-ellipse", "spheroid", "box2d", "box3d"
+    ])
+def test_mass_surface_area(actx_factory, name):
+    actx = actx_factory()
+
+    # {{{ cases
+
+    if name == "2-1-ellipse":
+        from mesh_data import EllipseMeshBuilder
+        builder = EllipseMeshBuilder(radius=3.1, aspect_ratio=2.0)
+        surface_area = _ellipse_surface_area(builder.radius, builder.aspect_ratio)
+    elif name == "spheroid":
+        from mesh_data import SpheroidMeshBuilder
+        builder = SpheroidMeshBuilder()
+        surface_area = _spheroid_surface_area(builder.radius, builder.aspect_ratio)
+    elif name == "box2d":
+        from mesh_data import BoxMeshBuilder
+        builder = BoxMeshBuilder(ambient_dim=2)
+        surface_area = 1.0
+    elif name == "box3d":
+        from mesh_data import BoxMeshBuilder
+        builder = BoxMeshBuilder(ambient_dim=3)
+        surface_area = 1.0
+    else:
+        raise ValueError("unknown geometry name: %s" % name)
+
+    # }}}
+
+    # {{{ convergence
+
+    from pytools.convergence import EOCRecorder
+    eoc = EOCRecorder()
+
+    for resolution in builder.resolutions:
+        mesh = builder.get_mesh(resolution, builder.mesh_order)
+        discr = DiscretizationCollection(actx, mesh, order=builder.order)
+        volume_discr = discr.discr_from_dd(dof_desc.DD_VOLUME)
+
+        logger.info("ndofs:     %d", volume_discr.ndofs)
+        logger.info("nelements: %d", volume_discr.mesh.nelements)
+
+        # {{{ compute surface area
+
+        dd = dof_desc.DD_VOLUME
+        sym_op = sym.NodalSum(dd)(sym.MassOperator(dd, dd)(sym.Ones(dd)))
+        approx_surface_area = bind(discr, sym_op)(actx)
+
+        logger.info("surface: got {:.5e} / expected {:.5e}".format(
+            approx_surface_area, surface_area))
+        area_error = abs(approx_surface_area - surface_area) / abs(surface_area)
+
+        # }}}
+
+        h_max = bind(discr, sym.h_max_from_volume(
+            discr.ambient_dim, dim=discr.dim, dd=dd))(actx)
+        eoc.add_data_point(h_max, area_error + 1.0e-16)
+
+    # }}}
+
+    logger.info("surface area error\n%s", str(eoc))
+
+    assert eoc.max_error() < 1.0e-14 \
+            or eoc.order_estimate() > builder.order
+
+# }}}
+
+
+# {{{ surface mass inverse
+
+@pytest.mark.parametrize("name", ["2-1-ellipse", "spheroid"])
+def test_surface_mass_operator_inverse(actx_factory, name):
+    actx = actx_factory()
+
+    # {{{ cases
+
+    if name == "2-1-ellipse":
+        from mesh_data import EllipseMeshBuilder
+        builder = EllipseMeshBuilder(radius=3.1, aspect_ratio=2.0)
+    elif name == "spheroid":
+        from mesh_data import SpheroidMeshBuilder
+        builder = SpheroidMeshBuilder()
+    else:
+        raise ValueError("unknown geometry name: %s" % name)
+
+    # }}}
+
+    # {{{ convergence
+
+    from pytools.convergence import EOCRecorder
+    eoc = EOCRecorder()
+
+    for resolution in builder.resolutions:
+        mesh = builder.get_mesh(resolution, builder.mesh_order)
+        discr = DiscretizationCollection(actx, mesh, order=builder.order)
+        volume_discr = discr.discr_from_dd(dof_desc.DD_VOLUME)
+
+        logger.info("ndofs:     %d", volume_discr.ndofs)
+        logger.info("nelements: %d", volume_discr.mesh.nelements)
+
+        # {{{ compute inverse mass
+
+        dd = dof_desc.DD_VOLUME
+        sym_f = sym.cos(4.0 * sym.nodes(mesh.ambient_dim, dd)[0])
+        sym_op = sym.InverseMassOperator(dd, dd)(
+                sym.MassOperator(dd, dd)(sym.var("f")))
+
+        f = bind(discr, sym_f)(actx)
+        f_inv = bind(discr, sym_op)(actx, f=f)
+
+        inv_error = bind(discr,
+                sym.norm(2, sym.var("x") - sym.var("y"))
+                / sym.norm(2, sym.var("y")))(actx, x=f_inv, y=f)
+
+        # }}}
+
+        h_max = bind(discr, sym.h_max_from_volume(
+            discr.ambient_dim, dim=discr.dim, dd=dd))(actx)
+        eoc.add_data_point(h_max, inv_error)
+
+    # }}}
+
+    logger.info("inverse mass error\n%s", str(eoc))
+
+    # NOTE: both cases give 1.0e-16-ish at the moment, but just to be on the
+    # safe side, choose a slightly larger tolerance
+    assert eoc.max_error() < 1.0e-14
+
+# }}}
+
+
+# {{{ surface face normal orthogonality
+
+@pytest.mark.parametrize("mesh_name", ["2-1-ellipse", "spheroid"])
+def test_face_normal_surface(actx_factory, mesh_name):
+    """Check that face normals are orthogonal to the surface normal"""
+    actx = actx_factory()
+
+    # {{{ geometry
+
+    if mesh_name == "2-1-ellipse":
+        from mesh_data import EllipseMeshBuilder
+        builder = EllipseMeshBuilder(radius=3.1, aspect_ratio=2.0)
+    elif mesh_name == "spheroid":
+        from mesh_data import SpheroidMeshBuilder
+        builder = SpheroidMeshBuilder()
+    else:
+        raise ValueError("unknown mesh name: %s" % mesh_name)
+
+    mesh = builder.get_mesh(builder.resolutions[0], builder.mesh_order)
+    discr = DiscretizationCollection(actx, mesh, order=builder.order)
+
+    volume_discr = discr.discr_from_dd(dof_desc.DD_VOLUME)
+    logger.info("ndofs:    %d", volume_discr.ndofs)
+    logger.info("nelements: %d", volume_discr.mesh.nelements)
+
+    # }}}
+
+    # {{{ symbolic
+    from meshmode.discretization.connection import FACE_RESTR_INTERIOR
+
+    dv = dof_desc.DD_VOLUME
+    df = dof_desc.as_dofdesc(FACE_RESTR_INTERIOR)
+
+    ambient_dim = mesh.ambient_dim
+    dim = mesh.dim
+
+    sym_surf_normal = sym.project(dv, df)(
+            sym.surface_normal(ambient_dim, dim=dim, dd=dv).as_vector()
+            )
+    sym_surf_normal = sym_surf_normal / sym.sqrt(sum(sym_surf_normal**2))
+
+    sym_face_normal_i = sym.normal(df, ambient_dim, dim=dim - 1)
+    sym_face_normal_e = sym.OppositeInteriorFaceSwap(df)(sym_face_normal_i)
+
+    if mesh.ambient_dim == 3:
+        # NOTE: there's only one face tangent in 3d
+        sym_face_tangent = (
+                sym.pseudoscalar(ambient_dim, dim - 1, dd=df)
+                / sym.area_element(ambient_dim, dim - 1, dd=df)).as_vector()
+
+    # }}}
+
+    # {{{ checks
+
+    def _eval_error(x):
+        return bind(discr, sym.norm(np.inf, sym.var("x", dd=df), dd=df))(actx, x=x)
+
+    rtol = 1.0e-14
+
+    surf_normal = bind(discr, sym_surf_normal)(actx)
+
+    face_normal_i = bind(discr, sym_face_normal_i)(actx)
+    face_normal_e = bind(discr, sym_face_normal_e)(actx)
+
+    # check interpolated surface normal is orthogonal to face normal
+    error = _eval_error(surf_normal.dot(face_normal_i))
+    logger.info("error[n_dot_i]:    %.5e", error)
+    assert error < rtol
+
+    # check angle between two neighboring elements
+    error = _eval_error(face_normal_i.dot(face_normal_e) + 1.0)
+    logger.info("error[i_dot_e]:    %.5e", error)
+    assert error > rtol
+
+    # check orthogonality with face tangent
+    if ambient_dim == 3:
+        face_tangent = bind(discr, sym_face_tangent)(actx)
+
+        error = _eval_error(face_tangent.dot(face_normal_i))
+        logger.info("error[t_dot_i]:  %.5e", error)
+        assert error < 5 * rtol
+
+    # }}}
+
+# }}}
+
+
+# {{{ diff operator
+
+@pytest.mark.parametrize("dim", [1, 2, 3])
+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.
+    """
+
+    actx = actx_factory()
+
+    from pytools.convergence import EOCRecorder
+    axis_eoc_recs = [EOCRecorder() for axis in range(dim)]
+
+    for n in [4, 8, 16]:
+        mesh = mgen.generate_regular_rect_mesh(a=(-0.5,)*dim, b=(0.5,)*dim,
+                nelements_per_axis=(n,)*dim, order=4)
+
+        discr = DiscretizationCollection(actx, mesh, order=4)
+        nabla = sym.nabla(dim)
+
+        for axis in range(dim):
+            x = sym.nodes(dim)
+
+            f = bind(discr, sym.sin(3*x[axis]))(actx)
+            df = bind(discr, 3*sym.cos(3*x[axis]))(actx)
+
+            sym_op = nabla[axis](sym.var("f"))
+            bound_op = bind(discr, sym_op)
+            df_num = bound_op(f=f)
+
+            linf_error = actx.np.linalg.norm(df_num - df, ord=np.inf)
+            axis_eoc_recs[axis].add_data_point(1/n, linf_error)
+
+    for axis, eoc_rec in enumerate(axis_eoc_recs):
+        logger.info("axis %d\n%s", axis, eoc_rec)
+        assert eoc_rec.order_estimate() > order - 0.25
+
+# }}}
+
+
+# {{{ divergence theorem
+
+def test_2d_gauss_theorem(actx_factory):
+    """Verify Gauss's theorem explicitly on a mesh"""
+
+    pytest.importorskip("meshpy")
+
+    from meshpy.geometry import make_circle, GeometryBuilder
+    from meshpy.triangle import MeshInfo, build
+
+    geob = GeometryBuilder()
+    geob.add_geometry(*make_circle(1))
+    mesh_info = MeshInfo()
+    geob.set(mesh_info)
+
+    mesh_info = build(mesh_info)
+
+    from meshmode.mesh.io import from_meshpy
+    from meshmode.mesh import BTAG_ALL
+
+    mesh = from_meshpy(mesh_info, order=1)
+
+    actx = actx_factory()
+
+    discr = DiscretizationCollection(actx, mesh, order=2)
+
+    def f(x):
+        return flat_obj_array(
+                sym.sin(3*x[0])+sym.cos(3*x[1]),
+                sym.sin(2*x[0])+sym.cos(x[1]))
+
+    gauss_err = bind(discr,
+            sym.integral((
+                sym.nabla(2) * f(sym.nodes(2))
+                ).sum())
+            -  # noqa: W504
+            sym.integral(
+                sym.project("vol", BTAG_ALL)(f(sym.nodes(2)))
+                .dot(sym.normal(BTAG_ALL, 2)),
+                dd=BTAG_ALL)
+            )(actx)
+
+    assert abs(gauss_err) < 1e-13
+
+
+@pytest.mark.parametrize("mesh_name", ["2-1-ellipse", "spheroid"])
+def test_surface_divergence_theorem(actx_factory, mesh_name, visualize=False):
+    r"""Check the surface divergence theorem.
+
+        .. math::
+
+            \int_Sigma \phi \nabla_i f_i =
+            \int_\Sigma \nabla_i \phi f_i +
+            \int_\Sigma \kappa \phi f_i n_i +
+            \int_{\partial \Sigma} \phi f_i m_i
+
+        where :math:`n_i` is the surface normal and :class:`m_i` is the
+        face normal (which should be orthogonal to both the surface normal
+        and the face tangent).
+    """
+    actx = actx_factory()
+
+    # {{{ cases
+
+    if mesh_name == "2-1-ellipse":
+        from mesh_data import EllipseMeshBuilder
+        builder = EllipseMeshBuilder(radius=3.1, aspect_ratio=2.0)
+    elif mesh_name == "spheroid":
+        from mesh_data import SpheroidMeshBuilder
+        builder = SpheroidMeshBuilder()
+    elif mesh_name == "circle":
+        from mesh_data import EllipseMeshBuilder
+        builder = EllipseMeshBuilder(radius=1.0, aspect_ratio=1.0)
+    elif mesh_name == "starfish":
+        from mesh_data import StarfishMeshBuilder
+        builder = StarfishMeshBuilder()
+    elif mesh_name == "sphere":
+        from mesh_data import SphereMeshBuilder
+        builder = SphereMeshBuilder(radius=1.0, mesh_order=16)
+    else:
+        raise ValueError("unknown mesh name: %s" % mesh_name)
+
+    # }}}
+
+    # {{{ convergene
+
+    def f(x):
+        return flat_obj_array(
+                sym.sin(3*x[1]) + sym.cos(3*x[0]) + 1.0,
+                sym.sin(2*x[0]) + sym.cos(x[1]),
+                3.0 * sym.cos(x[0] / 2) + sym.cos(x[1]),
+                )[:ambient_dim]
+
+    from pytools.convergence import EOCRecorder
+    eoc_global = EOCRecorder()
+    eoc_local = EOCRecorder()
+
+    theta = np.pi / 3.33
+    ambient_dim = builder.ambient_dim
+    if ambient_dim == 2:
+        mesh_rotation = np.array([
+            [np.cos(theta), -np.sin(theta)],
+            [np.sin(theta), np.cos(theta)],
+            ])
+    else:
+        mesh_rotation = np.array([
+            [1.0, 0.0, 0.0],
+            [0.0, np.cos(theta), -np.sin(theta)],
+            [0.0, np.sin(theta), np.cos(theta)],
+            ])
+
+    mesh_offset = np.array([0.33, -0.21, 0.0])[:ambient_dim]
+
+    for i, resolution in enumerate(builder.resolutions):
+        from meshmode.mesh.processing import affine_map
+        from meshmode.discretization.connection import FACE_RESTR_ALL
+
+        mesh = builder.get_mesh(resolution, builder.mesh_order)
+        mesh = affine_map(mesh, A=mesh_rotation, b=mesh_offset)
+
+        from meshmode.discretization.poly_element import \
+                QuadratureSimplexGroupFactory
+        discr = DiscretizationCollection(
+            actx, mesh, order=builder.order,
+            discr_tag_to_group_factory={
+                    "product": QuadratureSimplexGroupFactory(2 * builder.order)
+            }
+        )
+
+        volume = discr.discr_from_dd(dof_desc.DD_VOLUME)
+        logger.info("ndofs:     %d", volume.ndofs)
+        logger.info("nelements: %d", volume.mesh.nelements)
+
+        dd = dof_desc.DD_VOLUME
+        dq = dd.with_discr_tag("product")
+        df = dof_desc.as_dofdesc(FACE_RESTR_ALL)
+        ambient_dim = discr.ambient_dim
+        dim = discr.dim
+
+        # variables
+        sym_f = f(sym.nodes(ambient_dim, dd=dd))
+        sym_f_quad = f(sym.nodes(ambient_dim, dd=dq))
+        sym_kappa = sym.summed_curvature(ambient_dim, dim=dim, dd=dq)
+        sym_normal = sym.surface_normal(ambient_dim, dim=dim, dd=dq).as_vector()
+
+        sym_face_normal = sym.normal(df, ambient_dim, dim=dim - 1)
+        sym_face_f = sym.project(dd, df)(sym_f)
+
+        # operators
+        sym_stiff = sum(
+                sym.StiffnessOperator(d)(f) for d, f in enumerate(sym_f)
+                )
+        sym_stiff_t = sum(
+                sym.StiffnessTOperator(d)(f) for d, f in enumerate(sym_f)
+                )
+        sym_k = sym.MassOperator(dq, dd)(sym_kappa * sym_f_quad.dot(sym_normal))
+        sym_flux = sym.FaceMassOperator()(sym_face_f.dot(sym_face_normal))
+
+        # sum everything up
+        sym_op_global = sym.NodalSum(dd)(
+                sym_stiff - (sym_stiff_t + sym_k))
+        sym_op_local = sym.ElementwiseSumOperator(dd)(
+                sym_stiff - (sym_stiff_t + sym_k + sym_flux))
+
+        # evaluate
+        op_global = bind(discr, sym_op_global)(actx)
+        op_local = bind(discr, sym_op_local)(actx)
+
+        err_global = abs(op_global)
+        err_local = bind(discr, sym.norm(np.inf, sym.var("x")))(actx, x=op_local)
+        logger.info("errors: global %.5e local %.5e", err_global, err_local)
+
+        # compute max element size
+        h_max = bind(discr, sym.h_max_from_volume(
+            discr.ambient_dim, dim=discr.dim, dd=dd))(actx)
+        eoc_global.add_data_point(h_max, err_global)
+        eoc_local.add_data_point(h_max, err_local)
+
+        if visualize:
+            from grudge.shortcuts import make_visualizer
+            vis = make_visualizer(discr, vis_order=builder.order)
+
+            filename = f"surface_divergence_theorem_{mesh_name}_{i:04d}.vtu"
+            vis.write_vtk_file(filename, [
+                ("r", actx.np.log10(op_local))
+                ], overwrite=True)
+
+    # }}}
+
+    order = min(builder.order, builder.mesh_order) - 0.5
+    logger.info("\n%s", str(eoc_global))
+    logger.info("\n%s", str(eoc_local))
+
+    assert eoc_global.max_error() < 1.0e-12 \
+            or eoc_global.order_estimate() > order - 0.5
+
+    assert eoc_local.max_error() < 1.0e-12 \
+            or eoc_local.order_estimate() > order - 0.5
+
+# }}}
+
+
+# {{{ operator collector determinism
+
+def test_op_collector_order_determinism():
+    class TestOperator(sym.Operator):
+
+        def __init__(self):
+            sym.Operator.__init__(self, dof_desc.DD_VOLUME, dof_desc.DD_VOLUME)
+
+        mapper_method = "map_test_operator"
+
+    from grudge.symbolic.mappers import BoundOperatorCollector
+
+    class TestBoundOperatorCollector(BoundOperatorCollector):
+
+        def map_test_operator(self, expr):
+            return self.map_operator(expr)
+
+    v0 = sym.var("v0")
+    ob0 = sym.OperatorBinding(TestOperator(), v0)
+
+    v1 = sym.var("v1")
+    ob1 = sym.OperatorBinding(TestOperator(), v1)
+
+    # The output order isn't significant, but it should always be the same.
+    assert list(TestBoundOperatorCollector(TestOperator)(ob0 + ob1)) == [ob0, ob1]
+
+# }}}
+
+
+# {{{ bessel
+
+def test_bessel(actx_factory):
+    actx = actx_factory()
+
+    dims = 2
+
+    mesh = mgen.generate_regular_rect_mesh(
+            a=(0.1,)*dims,
+            b=(1.0,)*dims,
+            nelements_per_axis=(8,)*dims)
+
+    discr = DiscretizationCollection(actx, mesh, order=3)
+
+    nodes = sym.nodes(dims)
+    r = sym.cse(sym.sqrt(nodes[0]**2 + nodes[1]**2))
+
+    # https://dlmf.nist.gov/10.6.1
+    n = 3
+    bessel_zero = (
+            sym.bessel_j(n+1, r)
+            + sym.bessel_j(n-1, r)
+            - 2*n/r * sym.bessel_j(n, r))
+
+    z = bind(discr, sym.norm(2, bessel_zero))(actx)
+
+    assert z < 1e-15
+
+# }}}
+
+
+# {{{ function symbol
+
+def test_external_call(actx_factory):
+    actx = actx_factory()
+
+    def double(queue, x):
+        return 2 * x
+
+    dims = 2
+
+    mesh = mgen.generate_regular_rect_mesh(
+            a=(0,) * dims, b=(1,) * dims, nelements_per_axis=(4,) * dims)
+    discr = DiscretizationCollection(actx, mesh, order=1)
+
+    ones = sym.Ones(dof_desc.DD_VOLUME)
+    op = (
+            ones * 3
+            + sym.FunctionSymbol("double")(ones))
+
+    from grudge.function_registry import (
+            base_function_registry, register_external_function)
+
+    freg = register_external_function(
+            base_function_registry,
+            "double",
+            implementation=double,
+            dd=dof_desc.DD_VOLUME)
+
+    bound_op = bind(discr, op, function_registry=freg)
+
+    result = bound_op(actx, double=double)
+    assert actx.to_numpy(flatten(result) == 5).all()
+
+
+@pytest.mark.parametrize("array_type", ["scalar", "vector"])
+def test_function_symbol_array(actx_factory, array_type):
+    """Test if `FunctionSymbol` distributed properly over object arrays."""
+
+    actx = actx_factory()
+
+    dim = 2
+    mesh = mgen.generate_regular_rect_mesh(
+            a=(-0.5,)*dim, b=(0.5,)*dim,
+            nelements_per_axis=(8,)*dim, order=4)
+    discr = DiscretizationCollection(actx, mesh, order=4)
+    volume_discr = discr.discr_from_dd(dof_desc.DD_VOLUME)
+
+    if array_type == "scalar":
+        sym_x = sym.var("x")
+        x = thaw(actx, actx.np.cos(volume_discr.nodes()[0]))
+    elif array_type == "vector":
+        sym_x = sym.make_sym_array("x", dim)
+        x = thaw(actx, volume_discr.nodes())
+    else:
+        raise ValueError("unknown array type")
+
+    norm = bind(discr, sym.norm(2, sym_x))(x=x)
+    assert isinstance(norm, float)
+
+# }}}
+
+
+@pytest.mark.parametrize("p", [2, np.inf])
+def test_norm_obj_array(actx_factory, p):
+    """Test :func:`grudge.symbolic.operators.norm` for object arrays."""
+
+    actx = actx_factory()
+
+    dim = 2
+    mesh = mgen.generate_regular_rect_mesh(
+            a=(-0.5,)*dim, b=(0.5,)*dim,
+            nelements_per_axis=(8,)*dim, order=1)
+    discr = DiscretizationCollection(actx, mesh, order=4)
+
+    w = make_obj_array([1.0, 2.0, 3.0])[:dim]
+
+    # {{ scalar
+
+    sym_w = sym.var("w")
+    norm = bind(discr, sym.norm(p, sym_w))(actx, w=w[0])
+
+    norm_exact = w[0]
+    logger.info("norm: %.5e %.5e", norm, norm_exact)
+    assert abs(norm - norm_exact) < 1.0e-14
+
+    # }}}
+
+    # {{{ vector
+
+    sym_w = sym.make_sym_array("w", dim)
+    norm = bind(discr, sym.norm(p, sym_w))(actx, w=w)
+
+    norm_exact = np.sqrt(np.sum(w**2)) if p == 2 else np.max(w)
+    logger.info("norm: %.5e %.5e", norm, norm_exact)
+    assert abs(norm - norm_exact) < 1.0e-14
+
+    # }}}
+
+
+def test_map_if(actx_factory):
+    """Test :meth:`grudge.symbolic.execution.ExecutionMapper.map_if` handling
+    of scalar conditions.
+    """
+
+    actx = actx_factory()
+
+    dim = 2
+    mesh = mgen.generate_regular_rect_mesh(
+            a=(-0.5,)*dim, b=(0.5,)*dim,
+            nelements_per_axis=(8,)*dim, order=4)
+    discr = DiscretizationCollection(actx, mesh, order=4)
+
+    sym_if = sym.If(sym.Comparison(2.0, "<", 1.0e-14), 1.0, 2.0)
+    bind(discr, sym_if)(actx)
+
+
+def test_empty_boundary(actx_factory):
+    # https://github.com/inducer/grudge/issues/54
+
+    from meshmode.mesh import BTAG_NONE
+
+    actx = actx_factory()
+
+    dim = 2
+    mesh = mgen.generate_regular_rect_mesh(
+            a=(-0.5,)*dim, b=(0.5,)*dim,
+            nelements_per_axis=(8,)*dim, order=4)
+    discr = DiscretizationCollection(actx, mesh, order=4)
+    normal = bind(discr,
+            sym.normal(BTAG_NONE, dim, dim=dim - 1))(actx)
+    from meshmode.dof_array import DOFArray
+    for component in normal:
+        assert isinstance(component, DOFArray)
+        assert len(component) == len(discr.discr_from_dd(BTAG_NONE).groups)
+
+
+def test_operator_compiler_overwrite(actx_factory):
+    """Tests that the same expression in ``eval_code`` and ``discr_code``
+    does not confuse the OperatorCompiler in grudge/symbolic/compiler.py.
+    """
+
+    actx = actx_factory()
+
+    ambient_dim = 2
+    target_order = 4
+
+    from meshmode.mesh.generation import generate_regular_rect_mesh
+    mesh = generate_regular_rect_mesh(
+            a=(-0.5,)*ambient_dim, b=(0.5,)*ambient_dim,
+            n=(8,)*ambient_dim, order=1)
+    discr = DiscretizationCollection(actx, mesh, order=target_order)
+
+    # {{{ test
+
+    sym_u = sym.nodes(ambient_dim)
+    sym_div_u = sum(d(u) for d, u in zip(sym.nabla(ambient_dim), sym_u))
+
+    div_u = bind(discr, sym_div_u)(actx)
+    error = bind(discr, sym.norm(2, sym.var("x")))(actx, x=div_u - discr.dim)
+    logger.info("error: %.5e", error)
+
+    # }}}
+
+
+@pytest.mark.parametrize("ambient_dim", [
+    2,
+    # FIXME, cf. https://github.com/inducer/grudge/pull/78/
+    pytest.param(3, marks=pytest.mark.xfail)
+    ])
+def test_incorrect_assignment_aggregation(actx_factory, ambient_dim):
+    """Tests that the greedy assignemnt aggregation code works on a non-trivial
+    expression (on which it didn't work at the time of writing).
+    """
+
+    actx = actx_factory()
+
+    target_order = 4
+
+    from meshmode.mesh.generation import generate_regular_rect_mesh
+    mesh = generate_regular_rect_mesh(
+            a=(-0.5,)*ambient_dim, b=(0.5,)*ambient_dim,
+            n=(8,)*ambient_dim, order=1)
+    discr = DiscretizationCollection(actx, mesh, order=target_order)
+
+    # {{{ test with a relative norm
+
+    from grudge.dof_desc import DD_VOLUME
+    dd = DD_VOLUME
+    sym_x = sym.make_sym_array("y", ambient_dim, dd=dd)
+    sym_y = sym.make_sym_array("y", ambient_dim, dd=dd)
+
+    sym_norm_y = sym.norm(2, sym_y, dd=dd)
+    sym_norm_d = sym.norm(2, sym_x - sym_y, dd=dd)
+    sym_op = sym_norm_d / sym_norm_y
+    logger.info("%s", sym.pretty(sym_op))
+
+    # FIXME: this shouldn't raise a RuntimeError
+    with pytest.raises(RuntimeError):
+        bind(discr, sym_op)(actx, x=1.0, y=discr.discr_from_dd(dd).nodes())
+
+    # }}}
+
+    # {{{ test with repeated mass inverses
+
+    sym_minv_y = sym.cse(sym.InverseMassOperator()(sym_y), "minv_y")
+
+    sym_u = make_obj_array([0.5 * sym.Ones(dd), 0.0, 0.0])[:ambient_dim]
+    sym_div_u = sum(d(u) for d, u in zip(sym.nabla(ambient_dim), sym_u))
+
+    sym_op = sym.MassOperator(dd)(sym_u) \
+            + sym.MassOperator(dd)(sym_minv_y * sym_div_u)
+    logger.info("%s", sym.pretty(sym_op))
+
+    # FIXME: this shouldn't raise a RuntimeError either
+    bind(discr, sym_op)(actx, y=discr.discr_from_dd(dd).nodes())
+
+    # }}}
+
+
+# You can test individual routines by typing
+# $ python test_grudge.py 'test_routine()'
+
+if __name__ == "__main__":
+    import sys
+    if len(sys.argv) > 1:
+        exec(sys.argv[1])
+    else:
+        pytest.main([__file__])
+
+# vim: fdm=marker
diff --git a/test/test_modal_connections.py b/test/test_modal_connections.py
index eeb9a9782f71ffc99cc41093ed84296a3155f599..0eb0b67f42a7f4b21e82850036aee9bd7ecb7862 100644
--- a/test/test_modal_connections.py
+++ b/test/test_modal_connections.py
@@ -21,10 +21,12 @@ THE SOFTWARE.
 """
 
 
-from meshmode.array_context import (  # noqa
+from arraycontext import (  # noqa
     pytest_generate_tests_for_pyopencl_array_context
     as pytest_generate_tests
 )
+from arraycontext.container.traversal import thaw
+
 from meshmode.discretization.poly_element import (
     # Simplex group factories
     InterpolatoryQuadratureSimplexGroupFactory,
@@ -34,8 +36,8 @@ from meshmode.discretization.poly_element import (
     LegendreGaussLobattoTensorProductGroupFactory,
     # Quadrature-based (non-interpolatory) group factories
     QuadratureSimplexGroupFactory
-    )
-from meshmode.dof_array import thaw
+)
+from meshmode.dof_array import flat_norm
 import meshmode.mesh.generation as mgen
 
 from grudge import DiscretizationCollection
@@ -74,7 +76,7 @@ def test_inverse_modal_connections(actx_factory, nodal_group_factory):
     dd_modal = dof_desc.DD_VOLUME_MODAL
     dd_volume = dof_desc.DD_VOLUME
 
-    x_nodal = thaw(actx, dcoll.discr_from_dd(dd_volume).nodes()[0])
+    x_nodal = thaw(dcoll.discr_from_dd(dd_volume).nodes()[0], actx)
     nodal_f = f(x_nodal)
 
     # Map nodal coefficients of f to modal coefficients
@@ -86,7 +88,7 @@ def test_inverse_modal_connections(actx_factory, nodal_group_factory):
 
     # This error should be small since we composed a map with
     # its inverse
-    err = actx.np.linalg.norm(nodal_f - nodal_f_2)
+    err = flat_norm(nodal_f - nodal_f_2)
 
     assert err <= 1e-13
 
@@ -117,7 +119,7 @@ def test_inverse_modal_connections_quadgrid(actx_factory):
     dd_quad = dof_desc.DOFDesc(dof_desc.DTAG_VOLUME_ALL,
                                dof_desc.DISCR_TAG_QUAD)
 
-    x_quad = thaw(actx, dcoll.discr_from_dd(dd_quad).nodes()[0])
+    x_quad = thaw(dcoll.discr_from_dd(dd_quad).nodes()[0], actx)
     quad_f = f(x_quad)
 
     # Map nodal coefficients of f to modal coefficients
@@ -129,6 +131,6 @@ def test_inverse_modal_connections_quadgrid(actx_factory):
 
     # This error should be small since we composed a map with
     # its inverse
-    err = actx.np.linalg.norm(quad_f - quad_f_2)
+    err = flat_norm(quad_f - quad_f_2)
 
     assert err <= 1e-11
diff --git a/test/test_mpi_communication.py b/test/test_mpi_communication.py
index 985b4beb9d2dacd3b7ed9c50d37799248d20e288..879d3bf87dc3d4ad8dfeb1c3512f1423ec1589f7 100644
--- a/test/test_mpi_communication.py
+++ b/test/test_mpi_communication.py
@@ -1,6 +1,7 @@
 __copyright__ = """
 Copyright (C) 2017 Ellis Hoag
 Copyright (C) 2017 Andreas Kloeckner
+Copyright (C) 2021 University of Illinois Board of Trustees
 """
 
 __license__ = """
@@ -29,15 +30,22 @@ import numpy as np
 import pyopencl as cl
 import logging
 
-from meshmode.array_context import PyOpenCLArrayContext
+from arraycontext.impl.pyopencl import PyOpenCLArrayContext
+from arraycontext.container.traversal import thaw
 
 logger = logging.getLogger(__name__)
 logging.basicConfig()
 logger.setLevel(logging.INFO)
 
-from grudge import sym, bind, DiscretizationCollection
+from grudge import DiscretizationCollection
 from grudge.shortcuts import set_up_rk4
 
+from meshmode.dof_array import flat_norm
+
+from pytools.obj_array import flat_obj_array
+
+import grudge.op as op
+
 
 def simple_mpi_communication_entrypoint():
     cl_ctx = cl.create_some_context()
@@ -65,30 +73,33 @@ def simple_mpi_communication_entrypoint():
     else:
         local_mesh = mesh_dist.receive_mesh_part()
 
-    vol_discr = DiscretizationCollection(actx, local_mesh, order=5,
+    dcoll = DiscretizationCollection(actx, local_mesh, order=5,
             mpi_communicator=comm)
 
-    sym_x = sym.nodes(local_mesh.dim)
-    myfunc_symb = sym.sin(np.dot(sym_x, [2, 3]))
-    myfunc = bind(vol_discr, myfunc_symb)(actx)
-
-    sym_all_faces_func = sym.cse(
-        sym.project("vol", "all_faces")(sym.var("myfunc")))
-    sym_int_faces_func = sym.cse(
-        sym.project("vol", "int_faces")(sym.var("myfunc")))
-    sym_bdry_faces_func = sym.cse(
-        sym.project(BTAG_ALL, "all_faces")(
-            sym.project("vol", BTAG_ALL)(sym.var("myfunc"))))
-
-    bound_face_swap = bind(vol_discr,
-        sym.project("int_faces", "all_faces")(
-            sym.OppositeInteriorFaceSwap("int_faces")(
-                sym_int_faces_func)
-            ) - (sym_all_faces_func - sym_bdry_faces_func)
-            )
+    x = thaw(op.nodes(dcoll), actx)
+    myfunc = actx.np.sin(np.dot(x, [2, 3]))
+
+    from grudge.dof_desc import as_dofdesc
 
-    hopefully_zero = bound_face_swap(myfunc=myfunc)
-    error = actx.np.linalg.norm(hopefully_zero, ord=np.inf)
+    dd_int = as_dofdesc("int_faces")
+    dd_vol = as_dofdesc("vol")
+    dd_af = as_dofdesc("all_faces")
+
+    all_faces_func = op.project(dcoll, dd_vol, dd_af, myfunc)
+    int_faces_func = op.project(dcoll, dd_vol, dd_int, myfunc)
+    bdry_faces_func = op.project(dcoll, BTAG_ALL, dd_af,
+                                 op.project(dcoll, dd_vol, BTAG_ALL, myfunc))
+
+    hopefully_zero = (
+        op.project(
+            dcoll, "int_faces", "all_faces",
+            dcoll.opposite_face_connection()(int_faces_func)
+        )
+        + sum(op.project(dcoll, tpair.dd, "all_faces", tpair.int)
+              for tpair in op.cross_rank_trace_pairs(dcoll, myfunc))
+    ) - (all_faces_func - bdry_faces_func)
+
+    error = flat_norm(hopefully_zero, ord=np.inf)
 
     print(__file__)
     with np.printoptions(threshold=100000000, suppress=True):
@@ -124,79 +135,65 @@ def mpi_communication_entrypoint():
         part_per_element = get_partition_by_pymetis(mesh, num_parts)
 
         local_mesh = mesh_dist.send_mesh_parts(mesh, part_per_element, num_parts)
+
+        del mesh
     else:
         local_mesh = mesh_dist.receive_mesh_part()
 
-    vol_discr = DiscretizationCollection(actx, local_mesh, order=order,
-                                               mpi_communicator=comm)
-
-    source_center = np.array([0.1, 0.22, 0.33])[:local_mesh.dim]
-    source_width = 0.05
-    source_omega = 3
-
-    sym_x = sym.nodes(local_mesh.dim)
-    sym_source_center_dist = sym_x - source_center
-    sym_t = sym.ScalarVariable("t")
+    dcoll = DiscretizationCollection(actx, local_mesh, order=order,
+                                     mpi_communicator=comm)
+
+    def source_f(actx, dcoll, t=0):
+        source_center = np.array([0.1, 0.22, 0.33])[:dcoll.dim]
+        source_width = 0.05
+        source_omega = 3
+        nodes = thaw(op.nodes(dcoll), actx)
+        source_center_dist = flat_obj_array(
+            [nodes[i] - source_center[i] for i in range(dcoll.dim)]
+        )
+        return (
+            np.sin(source_omega*t)
+            * actx.np.exp(
+                -np.dot(source_center_dist, source_center_dist)
+                / source_width**2
+            )
+        )
 
     from grudge.models.wave import WeakWaveOperator
     from meshmode.mesh import BTAG_ALL, BTAG_NONE
-    op = WeakWaveOperator(0.1, vol_discr.dim,
-            source_f=(
-                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")
-
-    from pytools.obj_array import flat_obj_array
-    fields = flat_obj_array(vol_discr.zeros(actx),
-            [vol_discr.zeros(actx) for i in range(vol_discr.dim)])
+
+    wave_op = WeakWaveOperator(
+        dcoll,
+        0.1,
+        source_f=source_f,
+        dirichlet_tag=BTAG_NONE,
+        neumann_tag=BTAG_NONE,
+        radiation_tag=BTAG_ALL,
+        flux_type="upwind"
+    )
+
+    fields = flat_obj_array(
+        dcoll.zeros(actx),
+        [dcoll.zeros(actx) for i in range(dcoll.dim)]
+    )
 
     # FIXME
-    # dt = op.estimate_rk4_timestep(vol_discr, fields=fields)
+    # dt = op.estimate_rk4_timestep(dcoll, fields=fields)
 
-    # FIXME: Should meshmode consider BTAG_PARTITION to be a boundary?
-    #           Fails because: "found faces without boundary conditions"
-    # op.check_bc_coverage(local_mesh)
+    wave_op.check_bc_coverage(local_mesh)
 
     from logpyle import LogManager, \
             add_general_quantities, \
-            add_run_info, \
-            IntervalTimer, EventCounter
+            add_run_info
     log_filename = None
     # NOTE: LogManager hangs when using a file on a shared directory.
     # log_filename = "grudge_log.dat"
     logmgr = LogManager(log_filename, "w", comm)
     add_run_info(logmgr)
     add_general_quantities(logmgr)
-    log_quantities =\
-        {"rank_data_swap_timer": IntervalTimer("rank_data_swap_timer",
-        "Time spent evaluating RankDataSwapAssign"),
-        "rank_data_swap_counter": EventCounter("rank_data_swap_counter",
-        "Number of RankDataSwapAssign instructions evaluated"),
-        "exec_timer": IntervalTimer("exec_timer",
-        "Total time spent executing instructions"),
-        "insn_eval_timer": IntervalTimer("insn_eval_timer",
-        "Time spend evaluating instructions"),
-        "future_eval_timer": IntervalTimer("future_eval_timer",
-        "Time spent evaluating futures"),
-        "busy_wait_timer": IntervalTimer("busy_wait_timer",
-        "Time wasted doing busy wait")}
-    for quantity in log_quantities.values():
-        logmgr.add_quantity(quantity)
-
-    logger.debug("\n%s", sym.pretty(op.sym_operator()))
-    bound_op = bind(vol_discr, op.sym_operator())
 
     def rhs(t, w):
-        val, rhs.profile_data = bound_op(profile_data=rhs.profile_data,
-                                         log_quantities=log_quantities,
-                                         t=t, w=w)
-        return val
-    rhs.profile_data = {}
+        return wave_op.operator(t, w)
 
     dt_stepper = set_up_rk4("w", dt, fields, rhs)
 
@@ -204,12 +201,10 @@ def mpi_communication_entrypoint():
     nsteps = int(final_t/dt)
     logger.info("[%04d] dt %.5e nsteps %4d", i_local_rank, dt, nsteps)
 
-    # from grudge.shortcuts import make_visualizer
-    # vis = make_visualizer(vol_discr, vis_order=order)
-
     step = 0
 
-    norm = bind(vol_discr, sym.norm(2, sym.var("u")))
+    def norm(u):
+        return op.norm(dcoll, u, 2)
 
     from time import time
     t_last_step = time()
@@ -220,33 +215,16 @@ def mpi_communication_entrypoint():
             assert event.component_id == "w"
 
             step += 1
-            logger.debug("[%04d] t = %.5e |u| = %.5e ellapsed %.5e",
-                    step, event.t,
-                    norm(u=event.state_component[0]),
-                    time() - t_last_step)
-
-            # if step % 10 == 0:
-            #     vis.write_vtk_file("rank%d-fld-%04d.vtu" % (i_local_rank, step),
-            #                        [("u", event.state_component[0]),
-            #                         ("v", event.state_component[1:])])
+            logger.info("[%04d] t = %.5e |u| = %.5e ellapsed %.5e",
+                        step, event.t,
+                        norm(u=event.state_component[0]),
+                        time() - t_last_step)
+
             t_last_step = time()
             logmgr.tick_after()
             logmgr.tick_before()
-    logmgr.tick_after()
 
-    def print_profile_data(data):
-        logger.info("""execute() for rank %d:\n
-            \tInstruction Evaluation: %g\n
-            \tFuture Evaluation: %g\n
-            \tBusy Wait: %g\n
-            \tTotal: %g seconds""",
-            i_local_rank,
-            data["insn_eval_time"] / data["total_time"] * 100,
-            data["future_eval_time"] / data["total_time"] * 100,
-            data["busy_wait_time"] / data["total_time"] * 100,
-            data["total_time"])
-
-    print_profile_data(rhs.profile_data)
+    logmgr.tick_after()
     logmgr.close()
     logger.info("Rank %d exiting", i_local_rank)
 
@@ -261,6 +239,7 @@ def test_mpi(num_ranks):
 
     from subprocess import check_call
     import sys
+    # NOTE: CI uses OpenMPI; -x to pass env vars. MPICH uses -env
     check_call([
         "mpiexec", "-np", str(num_ranks),
         "-x", "RUN_WITHIN_MPI=1",
@@ -276,6 +255,7 @@ def test_simple_mpi(num_ranks):
 
     from subprocess import check_call
     import sys
+    # NOTE: CI uses OpenMPI; -x to pass env vars. MPICH uses -env
     check_call([
         "mpiexec", "-np", str(num_ranks),
         "-x", "RUN_WITHIN_MPI=1",
diff --git a/test/test_op.py b/test/test_op.py
index d74cdfe6ce7a7682dfb12aca506d68f53807199c..2d12409ee3a20c8cfce8c02a539dbd7e31f9e2ed 100644
--- a/test/test_op.py
+++ b/test/test_op.py
@@ -24,7 +24,6 @@ THE SOFTWARE.
 import numpy as np
 
 import meshmode.mesh.generation as mgen
-from meshmode.dof_array import thaw
 
 from pytools.obj_array import make_obj_array
 
@@ -32,9 +31,12 @@ from grudge import op, DiscretizationCollection
 from grudge.dof_desc import DOFDesc
 
 import pytest
-from meshmode.array_context import (  # noqa
-        pytest_generate_tests_for_pyopencl_array_context
-        as pytest_generate_tests)
+
+from arraycontext import (  # noqa
+    pytest_generate_tests_for_pyopencl_array_context
+    as pytest_generate_tests
+)
+from arraycontext.container.traversal import thaw
 
 import logging
 
@@ -68,25 +70,25 @@ def test_gradient(actx_factory, form, dim, order, vectorize, nested,
         def f(x):
             result = dcoll.zeros(actx) + 1
             for i in range(dim-1):
-                result *= actx.np.sin(np.pi*x[i])
-            result *= actx.np.cos(np.pi/2*x[dim-1])
+                result = result * actx.np.sin(np.pi*x[i])
+            result = result * actx.np.cos(np.pi/2*x[dim-1])
             return result
 
         def grad_f(x):
             result = make_obj_array([dcoll.zeros(actx) + 1 for _ in range(dim)])
             for i in range(dim-1):
                 for j in range(i):
-                    result[i] *= actx.np.sin(np.pi*x[j])
-                result[i] *= np.pi*actx.np.cos(np.pi*x[i])
+                    result[i] = result[i] * actx.np.sin(np.pi*x[j])
+                result[i] = result[i] * np.pi*actx.np.cos(np.pi*x[i])
                 for j in range(i+1, dim-1):
-                    result[i] *= actx.np.sin(np.pi*x[j])
-                result[i] *= actx.np.cos(np.pi/2*x[dim-1])
+                    result[i] = result[i] * actx.np.sin(np.pi*x[j])
+                result[i] = result[i] * actx.np.cos(np.pi/2*x[dim-1])
             for j in range(dim-1):
-                result[dim-1] *= actx.np.sin(np.pi*x[j])
-            result[dim-1] *= -np.pi/2*actx.np.sin(np.pi/2*x[dim-1])
+                result[dim-1] = result[dim-1] * actx.np.sin(np.pi*x[j])
+            result[dim-1] = result[dim-1] * (-np.pi/2*actx.np.sin(np.pi/2*x[dim-1]))
             return result
 
-        x = thaw(actx, op.nodes(dcoll))
+        x = thaw(op.nodes(dcoll), actx)
 
         if vectorize:
             u = make_obj_array([(i+1)*f(x) for i in range(dim)])
@@ -96,7 +98,7 @@ def test_gradient(actx_factory, form, dim, order, vectorize, nested,
         def get_flux(u_tpair):
             dd = u_tpair.dd
             dd_allfaces = dd.with_dtag("all_faces")
-            normal = thaw(actx, op.normal(dcoll, dd))
+            normal = thaw(op.normal(dcoll, dd), actx)
             u_avg = u_tpair.avg
             if vectorize:
                 if nested:
@@ -121,8 +123,10 @@ def test_gradient(actx_factory, form, dim, order, vectorize, nested,
                 op.face_mass(dcoll,
                     dd_allfaces,
                     # Note: no boundary flux terms here because u_ext == u_int == 0
-                    get_flux(op.interior_trace_pair(dcoll, u)))
+                    sum(get_flux(utpair)
+                        for utpair in op.interior_trace_pairs(dcoll, u))
                 )
+            )
         else:
             raise ValueError("Invalid form argument.")
 
@@ -186,8 +190,8 @@ def test_divergence(actx_factory, form, dim, order, vectorize, nested,
         def f(x):
             result = make_obj_array([dcoll.zeros(actx) + (i+1) for i in range(dim)])
             for i in range(dim-1):
-                result *= actx.np.sin(np.pi*x[i])
-            result *= actx.np.cos(np.pi/2*x[dim-1])
+                result = result * actx.np.sin(np.pi*x[i])
+            result = result * actx.np.cos(np.pi/2*x[dim-1])
             return result
 
         def div_f(x):
@@ -195,20 +199,20 @@ def test_divergence(actx_factory, form, dim, order, vectorize, nested,
             for i in range(dim-1):
                 deriv = dcoll.zeros(actx) + (i+1)
                 for j in range(i):
-                    deriv *= actx.np.sin(np.pi*x[j])
-                deriv *= np.pi*actx.np.cos(np.pi*x[i])
+                    deriv = deriv * actx.np.sin(np.pi*x[j])
+                deriv = deriv * np.pi*actx.np.cos(np.pi*x[i])
                 for j in range(i+1, dim-1):
-                    deriv *= actx.np.sin(np.pi*x[j])
-                deriv *= actx.np.cos(np.pi/2*x[dim-1])
-                result += deriv
+                    deriv = deriv * actx.np.sin(np.pi*x[j])
+                deriv = deriv * actx.np.cos(np.pi/2*x[dim-1])
+                result = result + deriv
             deriv = dcoll.zeros(actx) + dim
             for j in range(dim-1):
-                deriv *= actx.np.sin(np.pi*x[j])
-            deriv *= -np.pi/2*actx.np.sin(np.pi/2*x[dim-1])
-            result += deriv
+                deriv = deriv * actx.np.sin(np.pi*x[j])
+            deriv = deriv * (-np.pi/2*actx.np.sin(np.pi/2*x[dim-1]))
+            result = result + deriv
             return result
 
-        x = thaw(actx, op.nodes(dcoll))
+        x = thaw(op.nodes(dcoll), actx)
 
         if vectorize:
             u = make_obj_array([(i+1)*f(x) for i in range(dim)])
@@ -220,7 +224,7 @@ def test_divergence(actx_factory, form, dim, order, vectorize, nested,
         def get_flux(u_tpair):
             dd = u_tpair.dd
             dd_allfaces = dd.with_dtag("all_faces")
-            normal = thaw(actx, op.normal(dcoll, dd))
+            normal = thaw(op.normal(dcoll, dd), actx)
             flux = u_tpair.avg @ normal
             return op.project(dcoll, dd, dd_allfaces, flux)
 
@@ -238,8 +242,10 @@ def test_divergence(actx_factory, form, dim, order, vectorize, nested,
                 op.face_mass(dcoll,
                     dd_allfaces,
                     # Note: no boundary flux terms here because u_ext == u_int == 0
-                    get_flux(op.interior_trace_pair(dcoll, u)))
+                    sum(get_flux(utpair)
+                        for utpair in op.interior_trace_pairs(dcoll, u))
                 )
+            )
         else:
             raise ValueError("Invalid form argument.")
 
diff --git a/unported-examples/advection/advection.py b/unported-examples/advection/advection.py
deleted file mode 100644
index 80cfa0a99fa95ee5bdbc9e636906d3ae784064ea..0000000000000000000000000000000000000000
--- a/unported-examples/advection/advection.py
+++ /dev/null
@@ -1,183 +0,0 @@
-__copyright__ = "Copyright (C) 2007 Andreas Kloeckner"
-
-__license__ = """
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-"""
-
-from __future__ import division
-from __future__ import absolute_import
-from __future__ import print_function
-import numpy
-import numpy.linalg as la
-
-
-
-
-def main(write_output=True, flux_type_arg="upwind"):
-    from grudge.tools import mem_checkpoint
-    from math import sin, cos, pi, sqrt
-    from math import floor
-
-    from grudge.backends import guess_run_context
-    rcon = guess_run_context()
-
-    def f(x):
-        return sin(pi*x)
-
-    def u_analytic(x, el, t):
-        return f((-numpy.dot(v, x)/norm_v+t*norm_v))
-
-    def boundary_tagger(vertices, el, face_nr, all_v):
-        if numpy.dot(el.face_normals[face_nr], v) < 0:
-            return ["inflow"]
-        else:
-            return ["outflow"]
-
-    dim = 2
-
-    if dim == 1:
-        v = numpy.array([1])
-        if rcon.is_head_rank:
-            from grudge.mesh.generator import make_uniform_1d_mesh
-            mesh = make_uniform_1d_mesh(0, 2, 10, periodic=True)
-    elif dim == 2:
-        v = numpy.array([2,0])
-        if rcon.is_head_rank:
-            from grudge.mesh.generator import make_disk_mesh
-            mesh = make_disk_mesh(boundary_tagger=boundary_tagger)
-    elif dim == 3:
-        v = numpy.array([0,0,1])
-        if rcon.is_head_rank:
-            from grudge.mesh.generator import make_cylinder_mesh, make_ball_mesh, make_box_mesh
-
-            mesh = make_cylinder_mesh(max_volume=0.04, height=2, boundary_tagger=boundary_tagger,
-                    periodic=False, radial_subdivisions=32)
-    else:
-        raise RuntimeError("bad number of dimensions")
-
-    norm_v = la.norm(v)
-
-    if rcon.is_head_rank:
-        mesh_data = rcon.distribute_mesh(mesh)
-    else:
-        mesh_data = rcon.receive_mesh()
-
-    if dim != 1:
-        mesh_data = mesh_data.reordered_by("cuthill")
-
-    discr = rcon.make_discretization(mesh_data, order=4)
-    vis_discr = discr
-
-    from grudge.visualization import VtkVisualizer
-    if write_output:
-        vis = VtkVisualizer(vis_discr, rcon, "fld")
-
-    # operator setup ----------------------------------------------------------
-    from grudge.data import \
-            ConstantGivenFunction, \
-            TimeConstantGivenFunction, \
-            TimeDependentGivenFunction
-    from grudge.models.advection import StrongAdvectionOperator, WeakAdvectionOperator
-    op = WeakAdvectionOperator(v,
-            inflow_u=TimeDependentGivenFunction(u_analytic),
-            flux_type=flux_type_arg)
-
-    u = discr.interpolate_volume_function(lambda x, el: u_analytic(x, el, 0))
-
-    # timestep setup ----------------------------------------------------------
-    from grudge.timestep.runge_kutta import LSRK4TimeStepper
-    stepper = LSRK4TimeStepper()
-
-    if rcon.is_head_rank:
-        print("%d elements" % len(discr.mesh.elements))
-
-    # diagnostics setup -------------------------------------------------------
-    from logpyle import LogManager, \
-            add_general_quantities, \
-            add_simulation_quantities, \
-            add_run_info
-
-    if write_output:
-        log_file_name = "advection.dat"
-    else:
-        log_file_name = None
-
-    logmgr = LogManager(log_file_name, "w", rcon.communicator)
-    add_run_info(logmgr)
-    add_general_quantities(logmgr)
-    add_simulation_quantities(logmgr)
-    discr.add_instrumentation(logmgr)
-
-    stepper.add_instrumentation(logmgr)
-
-    from grudge.log import Integral, LpNorm
-    u_getter = lambda: u
-    logmgr.add_quantity(Integral(u_getter, discr, name="int_u"))
-    logmgr.add_quantity(LpNorm(u_getter, discr, p=1, name="l1_u"))
-    logmgr.add_quantity(LpNorm(u_getter, discr, name="l2_u"))
-
-    logmgr.add_watches(["step.max", "t_sim.max", "l2_u", "t_step.max"])
-
-    # timestep loop -----------------------------------------------------------
-    rhs = op.bind(discr)
-
-    try:
-        from grudge.timestep import times_and_steps
-        step_it = times_and_steps(
-                final_time=3, logmgr=logmgr,
-                max_dt_getter=lambda t: op.estimate_timestep(discr,
-                    stepper=stepper, t=t, fields=u))
-
-        for step, t, dt in step_it:
-            if step % 5 == 0 and write_output:
-                visf = vis.make_file("fld-%04d" % step)
-                vis.add_data(visf, [
-                    ("u", discr.convert_volume(u, kind="numpy")),
-                    ], time=t, step=step)
-                visf.close()
-
-            u = stepper(u, t, dt, rhs)
-
-        true_u = discr.interpolate_volume_function(lambda x, el: u_analytic(x, el, t))
-        print(discr.norm(u-true_u))
-        assert discr.norm(u-true_u) < 1e-2
-    finally:
-        if write_output:
-            vis.close()
-
-        logmgr.close()
-        discr.close()
-
-
-
-
-if __name__ == "__main__":
-    main()
-
-
-
-
-# entry points for py.test ----------------------------------------------------
-def test_advection():
-    from pytools.test import mark_test
-    mark_long = mark_test.long
-
-    for flux_type in ["upwind", "central", "lf"]:
-        yield "advection with %s flux" % flux_type, \
-                mark_long(main), False, flux_type
diff --git a/unported-examples/benchmark_grudge/benchmark_mpi.py b/unported-examples/benchmark_grudge/benchmark_mpi.py
deleted file mode 100644
index 8c60ea3cf867b00432e3f992c7815572ce45f33d..0000000000000000000000000000000000000000
--- a/unported-examples/benchmark_grudge/benchmark_mpi.py
+++ /dev/null
@@ -1,134 +0,0 @@
-import os
-import numpy as np
-import pyopencl as cl
-
-from grudge import sym, bind, DiscretizationCollection
-from grudge.shortcuts import set_up_rk4
-
-
-def simple_wave_entrypoint(dim=2, num_elems=256, order=4, num_steps=30,
-                           log_filename="grudge.dat"):
-    cl_ctx = cl.create_some_context()
-    queue = cl.CommandQueue(cl_ctx)
-
-    from mpi4py import MPI
-    comm = MPI.COMM_WORLD
-    num_parts = comm.Get_size()
-    n = int(num_elems ** (1./dim))
-
-    from meshmode.distributed import MPIMeshDistributor
-    mesh_dist = MPIMeshDistributor(comm)
-
-    if mesh_dist.is_mananger_rank():
-        from meshmode.mesh.generation import generate_regular_rect_mesh
-        mesh = generate_regular_rect_mesh(a=(-0.5,)*dim,
-                                          b=(0.5,)*dim,
-                                          n=(n,)*dim)
-
-        from pymetis import part_graph
-        _, p = part_graph(num_parts,
-                          xadj=mesh.nodal_adjacency.neighbors_starts.tolist(),
-                          adjncy=mesh.nodal_adjacency.neighbors.tolist())
-        part_per_element = np.array(p)
-
-        local_mesh = mesh_dist.send_mesh_parts(mesh, part_per_element, num_parts)
-    else:
-        local_mesh = mesh_dist.receive_mesh_part()
-
-    vol_discr = DiscretizationCollection(cl_ctx, local_mesh, order=order,
-                                               mpi_communicator=comm)
-
-    source_center = np.array([0.1, 0.22, 0.33])[:local_mesh.dim]
-    source_width = 0.05
-    source_omega = 3
-
-    sym_x = sym.nodes(local_mesh.dim)
-    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, vol_discr.dim,
-            source_f=(
-                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")
-
-    from pytools.obj_array import join_fields
-    fields = join_fields(vol_discr.zeros(queue),
-            [vol_discr.zeros(queue) for i in range(vol_discr.dim)])
-
-    from logpyle import LogManager, \
-            add_general_quantities, \
-            add_run_info, \
-            IntervalTimer, EventCounter
-    # NOTE: LogManager hangs when using a file on a shared directory.
-    logmgr = LogManager(log_filename, "w", comm)
-    add_run_info(logmgr)
-    add_general_quantities(logmgr)
-    log_quantities =\
-        {"rank_data_swap_timer": IntervalTimer("rank_data_swap_timer",
-                        "Time spent evaluating RankDataSwapAssign"),
-        "rank_data_swap_counter": EventCounter("rank_data_swap_counter",
-                        "Number of RankDataSwapAssign instructions evaluated"),
-        "exec_timer": IntervalTimer("exec_timer",
-                        "Total time spent executing instructions"),
-        "insn_eval_timer": IntervalTimer("insn_eval_timer",
-                        "Time spend evaluating instructions"),
-        "future_eval_timer": IntervalTimer("future_eval_timer",
-                        "Time spent evaluating futures"),
-        "busy_wait_timer": IntervalTimer("busy_wait_timer",
-                        "Time wasted doing busy wait")}
-    for quantity in log_quantities.values():
-        logmgr.add_quantity(quantity)
-
-    bound_op = bind(vol_discr, op.sym_operator())
-
-    def rhs(t, w):
-        val, rhs.profile_data = bound_op(queue, profile_data=rhs.profile_data,
-                                                log_quantities=log_quantities,
-                                                t=t, w=w)
-        return val
-    rhs.profile_data = {}
-
-    dt = 0.04
-    dt_stepper = set_up_rk4("w", dt, fields, rhs)
-
-    logmgr.tick_before()
-    for event in dt_stepper.run(t_end=dt * num_steps):
-        if isinstance(event, dt_stepper.StateComputed):
-            logmgr.tick_after()
-            logmgr.tick_before()
-    logmgr.tick_after()
-
-    def print_profile_data(data):
-        print("""execute() for rank %d:
-            \tInstruction Evaluation: %f%%
-            \tFuture Evaluation: %f%%
-            \tBusy Wait: %f%%
-            \tTotal: %f seconds""" %
-            (comm.Get_rank(),
-             data['insn_eval_time'] / data['total_time'] * 100,
-             data['future_eval_time'] / data['total_time'] * 100,
-             data['busy_wait_time'] / data['total_time'] * 100,
-             data['total_time']))
-
-    print_profile_data(rhs.profile_data)
-    logmgr.close()
-
-
-if __name__ == "__main__":
-    assert "RUN_WITHIN_MPI" in os.environ, "Must run within mpi"
-    import sys
-    assert len(sys.argv) == 5, \
-        "Usage: %s %s num_elems order num_steps logfile" \
-        % (sys.executable, sys.argv[0])
-    simple_wave_entrypoint(num_elems=int(sys.argv[1]),
-                           order=int(sys.argv[2]),
-                           num_steps=int(sys.argv[3]),
-                           log_filename=sys.argv[4])
diff --git a/unported-examples/benchmark_grudge/run_benchmark.sh b/unported-examples/benchmark_grudge/run_benchmark.sh
deleted file mode 100755
index 72eaca2bdd62d934644333f8d78b5628df8217e9..0000000000000000000000000000000000000000
--- a/unported-examples/benchmark_grudge/run_benchmark.sh
+++ /dev/null
@@ -1,123 +0,0 @@
-#!/bin/bash
-
-# Weak scaling: We run our code on one computer, then we buy a second computer
-# and we can run twice as much code in the same amount of time.
-
-# Strong scaling: We run our code on one computer, then we buy a second computer
-# and we can run the same code in half the time.
-
-# Examples:
-# ./run_benchmark.sh -t WEAK -n 100 -r 20 -s 1000 -l ~/weak_scaling.dat -o weak_scaling.txt
-# ./run_benchmark.sh -t STRONG -n 100 -r 20 -s 1000 -l ~/strong_scaling.dat -o strong_scaling.txt
-
-set -eu
-
-# NOTE: benchmark_mpi.py hangs when logfile is in a shared directory.
-USAGE="Usage: $0 -t <WEAK|STRONG> -n num_elems -r order -s num_steps -l logfile -o outfile"
-while getopts "t:n:r:s:l:o:" OPT; do
-  case $OPT in
-    t)
-      case $OPTARG in
-        WEAK)
-          SCALING_TYPE='WEAK'
-          ;;
-        STRONG)
-          SCALING_TYPE='STRONG'
-          ;;
-        *)
-          echo $USAGE
-          exit 1
-          ;;
-      esac
-      ;;
-    n)
-      NUM_ELEMS=$OPTARG
-      ;;
-    r)
-      ORDER=$OPTARG
-      ;;
-    s)
-      NUM_STEPS=$OPTARG
-      ;;
-    l)
-      LOGFILE=$OPTARG
-      ;;
-    o)
-      OUTFILE=$OPTARG
-      ;;
-    *)
-      echo $USAGE
-      exit 1
-      ;;
-  esac
-done
-
-
-# NOTE: We want to make sure we run grudge in the right environment.
-SHARED="/home/eshoag2/shared"
-source $SHARED/miniconda3/bin/activate inteq
-PYTHON=$(which python)
-BENCHMARK_MPI="$SHARED/grudge/examples/benchmark_grudge/benchmark_mpi.py"
-
-# Assume HOSTS_LIST is sorted in increasing order starting with one host.
-HOSTS_LIST="\
-porter \
-porter,stout \
-porter,stout,koelsch"
-
-ENVIRONMENT_VARS="\
--x RUN_WITHIN_MPI=1 \
--x PYOPENCL_CTX=0 \
--x POCL_AFFINITY=1"
-
-PERF_EVENTS="\
-cpu-cycles,\
-instructions,\
-task-clock"
-
-TEMPDIR=$(mktemp -d)
-trap 'rm -rf $TEMPDIR' EXIT HUP INT QUIT TERM
-
-echo "$(date): Testing $SCALING_TYPE scaling" | tee -a $OUTFILE
-
-NUM_HOSTS=1
-BASE_NUM_ELEMS=$NUM_ELEMS
-for HOSTS in $HOSTS_LIST; do
-
-  if [ $SCALING_TYPE = 'WEAK' ]; then
-    NUM_ELEMS=$(echo $BASE_NUM_ELEMS $NUM_HOSTS | awk '{ print $1 * $2 }')
-  fi
-
-  BENCHMARK_CMD="$PYTHON $BENCHMARK_MPI $NUM_ELEMS $ORDER $NUM_STEPS $LOGFILE.trial$NUM_HOSTS"
-  # NOTE: mpiexec recently updated so some things might act weird.
-  MPI_CMD="mpiexec --output-filename $TEMPDIR -H $HOSTS $ENVIRONMENT_VARS $BENCHMARK_CMD"
-  echo "Executing: $MPI_CMD"
-
-  # NOTE: perf does not follow mpi accross different nodes.
-  # Instead, perf will follow all processes on the porter node.
-  echo "====================Using $NUM_HOSTS host(s)===================" >> $OUTFILE
-  START_TIME=$(date +%s)
-  perf stat --append -o $OUTFILE -e $PERF_EVENTS $MPI_CMD
-  DURATION=$(($(date +%s) - $START_TIME))
-  echo "Finished in $DURATION seconds"
-
-  echo "===================Output of Python===================" >> $OUTFILE
-  find $TEMPDIR -type f -exec cat {} \; >> $OUTFILE
-  echo "======================================================" >> $OUTFILE
-  rm -rf $TEMPDIR/*
-
-  if [ $NUM_HOSTS -eq 1 ]; then
-    BASE_DURATION=$DURATION
-  fi
-
-  # Efficiency is expected / actual
-  if [ $SCALING_TYPE = 'STRONG' ]; then
-    EFFICIENCY=$(echo $DURATION $BASE_DURATION $NUM_HOSTS | awk '{ print $2 / ($3 * $1) * 100"%" }')
-  elif [ $SCALING_TYPE = 'WEAK' ]; then
-    EFFICIENCY=$(echo $DURATION $BASE_DURATION | awk '{ print $2 / $1 * 100"%" }')
-  fi
-
-  echo "Efficiency for $SCALING_TYPE scaling is $EFFICIENCY for $NUM_HOSTS host(s)." | tee -a $OUTFILE
-
-  ((NUM_HOSTS++))
-done
diff --git a/unported-examples/burgers/burgers.py b/unported-examples/burgers/burgers.py
deleted file mode 100644
index 532e32869a9f5aa8fdb2065368aaab0deb92599a..0000000000000000000000000000000000000000
--- a/unported-examples/burgers/burgers.py
+++ /dev/null
@@ -1,246 +0,0 @@
-__copyright__ = "Copyright (C) 2007 Andreas Kloeckner"
-
-__license__ = """
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-"""
-
-
-from __future__ import division
-from __future__ import absolute_import
-from __future__ import print_function
-import numpy
-import numpy.linalg as la
-from math import sin, cos, pi, sqrt
-from pytools.test import mark_test
-
-
-
-
-class ExactTestCase:
-    a = 0
-    b = 150
-    final_time = 1000
-
-    def u0(self, x):
-        return self.u_exact(x, 0)
-
-    def u_exact(self, x, t):
-        # CAUTION: This gets the shock speed wrong as soon as the pulse
-        # starts interacting with itself.
-
-        def f(x, shock_loc):
-            if x < (t-40)/4:
-                return 1/4
-            else:
-                if t < 40:
-                    if x < (3*t)/4:
-                        return (x+15)/(t+20)
-                    elif x < (t+80)/4:
-                        return (x-30)/(t-40)
-                    else:
-                        return 1/4
-                else:
-                    if x < shock_loc:
-                        return (x+15)/(t+20)
-                    else:
-                        return 1/4
-
-        from math import sqrt
-
-        shock_loc = 30*sqrt(2*t+40)/sqrt(120) + t/4 - 10
-        shock_win = (shock_loc + 20) // self.b
-        x += shock_win * 150
-
-        x -= 20
-
-        return max(f(x, shock_loc), f(x-self.b, shock_loc-self.b))
-
-class OffCenterMigratingTestCase:
-    a = -pi
-    b = pi
-    final_time = 10
-
-    def u0(self, x):
-        return -0.4+sin(x+0.1)
-
-
-class CenteredStationaryTestCase:
-    # does funny things to P-P
-    a = -pi
-    b = pi
-    final_time = 10
-
-    def u0(self, x):
-        return -sin(x)
-
-class OffCenterStationaryTestCase:
-    # does funny things to P-P
-    a = -pi
-    b = pi
-    final_time = 10
-
-    def u0(self, x):
-        return -sin(x+0.3)
-
-
-
-def main(write_output=True, flux_type_arg="upwind",
-        #case = CenteredStationaryTestCase(),
-        #case = OffCenterStationaryTestCase(),
-        #case = OffCenterMigratingTestCase(),
-        case = ExactTestCase(),
-        ):
-    from grudge.backends import guess_run_context
-    rcon = guess_run_context()
-
-    order = 3
-    if rcon.is_head_rank:
-        if True:
-            from grudge.mesh.generator import make_uniform_1d_mesh
-            mesh = make_uniform_1d_mesh(case.a, case.b, 20, periodic=True)
-        else:
-            from grudge.mesh.generator import make_rect_mesh
-            print((pi*2)/(11*5*2))
-            mesh = make_rect_mesh((-pi, -1), (pi, 1),
-                    periodicity=(True, True),
-                    subdivisions=(11,5),
-                    max_area=(pi*2)/(11*5*2)
-                    )
-
-    if rcon.is_head_rank:
-        mesh_data = rcon.distribute_mesh(mesh)
-    else:
-        mesh_data = rcon.receive_mesh()
-
-    discr = rcon.make_discretization(mesh_data, order=order,
-            quad_min_degrees={"quad": 3*order})
-
-    if write_output:
-        from grudge.visualization import VtkVisualizer
-        vis = VtkVisualizer(discr, rcon, "fld")
-
-    # operator setup ----------------------------------------------------------
-    from grudge.second_order import IPDGSecondDerivative
-
-    from grudge.models.burgers import BurgersOperator
-    op = BurgersOperator(mesh.dimensions,
-            viscosity_scheme=IPDGSecondDerivative())
-
-    if rcon.is_head_rank:
-        print("%d elements" % len(discr.mesh.elements))
-
-    # exact solution ----------------------------------------------------------
-    import pymbolic
-    var = pymbolic.var
-
-    u = discr.interpolate_volume_function(lambda x, el: case.u0(x[0]))
-
-    # diagnostics setup -------------------------------------------------------
-    from logpyle import LogManager, \
-            add_general_quantities, \
-            add_simulation_quantities, \
-            add_run_info
-
-    if write_output:
-        log_file_name = "burgers.dat"
-    else:
-        log_file_name = None
-
-    logmgr = LogManager(log_file_name, "w", rcon.communicator)
-    add_run_info(logmgr)
-    add_general_quantities(logmgr)
-    add_simulation_quantities(logmgr)
-    discr.add_instrumentation(logmgr)
-
-    from grudge.log import LpNorm
-    u_getter = lambda: u
-    logmgr.add_quantity(LpNorm(u_getter, discr, p=1, name="l1_u"))
-
-    logmgr.add_watches(["step.max", "t_sim.max", "l1_u", "t_step.max"])
-
-    # timestep loop -----------------------------------------------------------
-    rhs = op.bind(discr)
-
-    from grudge.timestep.runge_kutta import ODE45TimeStepper, LSRK4TimeStepper
-    stepper = ODE45TimeStepper()
-
-    stepper.add_instrumentation(logmgr)
-
-    try:
-        from grudge.timestep import times_and_steps
-        # for visc=0.01
-        #stab_fac = 0.1 # RK4
-        #stab_fac = 1.6 # dumka3(3), central
-        #stab_fac = 3 # dumka3(4), central
-
-        #stab_fac = 0.01 # RK4
-        stab_fac = 0.2 # dumka3(3), central
-        #stab_fac = 3 # dumka3(4), central
-
-        dt = stab_fac*op.estimate_timestep(discr,
-                stepper=LSRK4TimeStepper(), t=0, fields=u)
-
-        step_it = times_and_steps(
-                final_time=case.final_time, logmgr=logmgr, max_dt_getter=lambda t: dt)
-        from grudge.symbolic import  InverseVandermondeOperator
-        inv_vdm = InverseVandermondeOperator().bind(discr)
-
-        for step, t, dt in step_it:
-            if step % 3 == 0 and write_output:
-                if hasattr(case, "u_exact"):
-                    extra_fields = [
-                            ("u_exact",
-                                discr.interpolate_volume_function(
-                                    lambda x, el: case.u_exact(x[0], t)))]
-                else:
-                    extra_fields = []
-
-                visf = vis.make_file("fld-%04d" % step)
-                vis.add_data(visf, [
-                    ("u", u),
-                    ] + extra_fields,
-                    time=t,
-                    step=step)
-                visf.close()
-
-            u = stepper(u, t, dt, rhs)
-
-        if isinstance(case, ExactTestCase):
-            assert discr.norm(u, 1) < 50
-
-    finally:
-        if write_output:
-            vis.close()
-
-        logmgr.save()
-
-
-
-
-if __name__ == "__main__":
-    main()
-
-
-
-
-# entry points for py.test ----------------------------------------------------
-@mark_test.long
-def test_stability():
-    main(write_output=False)
-
diff --git a/unported-examples/gas_dynamics/box-in-box.py b/unported-examples/gas_dynamics/box-in-box.py
deleted file mode 100644
index a6b9aaf7bbd232ed8286045d0e54289b447d3d90..0000000000000000000000000000000000000000
--- a/unported-examples/gas_dynamics/box-in-box.py
+++ /dev/null
@@ -1,242 +0,0 @@
-__copyright__ = "Copyright (C) 2008 Andreas Kloeckner"
-
-__license__ = """
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-"""
-
-from __future__ import division
-from __future__ import absolute_import
-from __future__ import print_function
-import numpy
-
-
-def make_boxmesh():
-    from meshpy.tet import MeshInfo, build
-    from meshpy.geometry import GeometryBuilder, Marker, make_box
-
-    geob = GeometryBuilder()
-
-    box_marker = Marker.FIRST_USER_MARKER
-    extent_small = 0.1*numpy.ones(3, dtype=numpy.float64)
-
-    geob.add_geometry(*make_box(-extent_small, extent_small))
-
-    # make small "separator box" for region attribute
-
-    geob.add_geometry(
-            *make_box(
-                -extent_small*4,
-                numpy.array([4, 0.4, 0.4], dtype=numpy.float64)))
-
-    geob.add_geometry(
-            *make_box(
-                numpy.array([-1, -1, -1], dtype=numpy.float64),
-                numpy.array([5, 1, 1], dtype=numpy.float64)))
-
-    mesh_info = MeshInfo()
-    geob.set(mesh_info)
-    mesh_info.set_holes([(0, 0, 0)])
-
-    # region attributes
-    mesh_info.regions.resize(1)
-    mesh_info.regions[0] = (
-            # point in region
-            list(extent_small*2) + [
-            # region number
-            1,
-            # max volume in region
-            #0.0001
-            0.005
-            ])
-
-    mesh = build(mesh_info, max_volume=0.02,
-            volume_constraints=True, attributes=True)
-    print("%d elements" % len(mesh.elements))
-    #mesh.write_vtk("box-in-box.vtk")
-    #print "done writing"
-
-    fvi2fm = mesh.face_vertex_indices_to_face_marker
-
-    face_marker_to_tag = {
-            box_marker: "noslip",
-            Marker.MINUS_X: "inflow",
-            Marker.PLUS_X: "outflow",
-            Marker.MINUS_Y: "inflow",
-            Marker.PLUS_Y: "inflow",
-            Marker.PLUS_Z: "inflow",
-            Marker.MINUS_Z: "inflow"
-            }
-
-    def bdry_tagger(fvi, el, fn, all_v):
-        face_marker = fvi2fm[fvi]
-        return [face_marker_to_tag[face_marker]]
-
-    from grudge.mesh import make_conformal_mesh
-    return make_conformal_mesh(
-            mesh.points, mesh.elements, bdry_tagger)
-
-
-def main():
-    from grudge.backends import guess_run_context
-    rcon = guess_run_context(["cuda"])
-
-    if rcon.is_head_rank:
-        mesh = make_boxmesh()
-        #from grudge.mesh import make_rect_mesh
-        #mesh = make_rect_mesh(
-        #       boundary_tagger=lambda fvi, el, fn, all_v: ["inflow"])
-        mesh_data = rcon.distribute_mesh(mesh)
-    else:
-        mesh_data = rcon.receive_mesh()
-
-    for order in [3]:
-        from pytools import add_python_path_relative_to_script
-        add_python_path_relative_to_script("..")
-
-        from gas_dynamics_initials import UniformMachFlow
-        box = UniformMachFlow(angle_of_attack=0)
-
-        from grudge.models.gas_dynamics import GasDynamicsOperator
-        op = GasDynamicsOperator(dimensions=3,
-                gamma=box.gamma, mu=box.mu,
-                prandtl=box.prandtl, spec_gas_const=box.spec_gas_const,
-                bc_inflow=box, bc_outflow=box, bc_noslip=box,
-                inflow_tag="inflow", outflow_tag="outflow", noslip_tag="noslip")
-
-        discr = rcon.make_discretization(mesh_data, order=order,
-                        debug=[
-                            #"cuda_no_plan",
-                            #"cuda_dump_kernels",
-                            #"dump_dataflow_graph",
-                            #"dump_optemplate_stages",
-                            #"dump_dataflow_graph",
-                            #"print_op_code",
-                            "cuda_no_plan_el_local",
-                            ],
-                        default_scalar_type=numpy.float32,
-                        tune_for=op.sym_operator())
-
-        from grudge.visualization import SiloVisualizer, VtkVisualizer  # noqa
-        #vis = VtkVisualizer(discr, rcon, "shearflow-%d" % order)
-        vis = SiloVisualizer(discr, rcon)
-
-        fields = box.volume_interpolant(0, discr)
-
-        navierstokes_ex = op.bind(discr)
-
-        max_eigval = [0]
-
-        def rhs(t, q):
-            ode_rhs, speed = navierstokes_ex(t, q)
-            max_eigval[0] = speed
-            return ode_rhs
-
-        rhs(0, fields)
-
-        if rcon.is_head_rank:
-            print("---------------------------------------------")
-            print("order %d" % order)
-            print("---------------------------------------------")
-            print("#elements=", len(mesh.elements))
-
-        from grudge.timestep import RK4TimeStepper
-        stepper = RK4TimeStepper()
-
-        # diagnostics setup ---------------------------------------------------
-        from logpyle import LogManager, add_general_quantities, \
-                add_simulation_quantities, add_run_info
-
-        logmgr = LogManager("navierstokes-%d.dat" % order, "w", rcon.communicator)
-        add_run_info(logmgr)
-        add_general_quantities(logmgr)
-        add_simulation_quantities(logmgr)
-        discr.add_instrumentation(logmgr)
-        stepper.add_instrumentation(logmgr)
-
-        logmgr.add_watches(["step.max", "t_sim.max", "t_step.max"])
-
-        from logpyle import LogQuantity
-
-        class ChangeSinceLastStep(LogQuantity):
-            """Records the change of a variable between a time step and the previous
-               one"""
-
-            def __init__(self, name="change"):
-                LogQuantity.__init__(self, name, "1", "Change since last time step")
-
-                self.old_fields = 0
-
-            def __call__(self):
-                result = discr.norm(fields - self.old_fields)
-                self.old_fields = fields
-                return result
-
-        logmgr.add_quantity(ChangeSinceLastStep())
-
-        # timestep loop -------------------------------------------------------
-        try:
-            from grudge.timestep import times_and_steps
-            step_it = times_and_steps(
-                    final_time=200,
-                    #max_steps=500,
-                    logmgr=logmgr,
-                    max_dt_getter=lambda t: op.estimate_timestep(discr,
-                        stepper=stepper, t=t, max_eigenvalue=max_eigval[0]))
-
-            for step, t, dt in step_it:
-                if step % 200 == 0:
-                #if False:
-                    visf = vis.make_file("box-%d-%06d" % (order, step))
-
-                    #rhs_fields = rhs(t, fields)
-
-                    vis.add_data(visf,
-                            [
-                                ("rho", discr.convert_volume(
-                                    op.rho(fields), kind="numpy")),
-                                ("e", discr.convert_volume(
-                                    op.e(fields), kind="numpy")),
-                                ("rho_u", discr.convert_volume(
-                                    op.rho_u(fields), kind="numpy")),
-                                ("u", discr.convert_volume(
-                                    op.u(fields), kind="numpy")),
-
-                                # ("rhs_rho", discr.convert_volume(
-                                #     op.rho(rhs_fields), kind="numpy")),
-                                # ("rhs_e", discr.convert_volume(
-                                #     op.e(rhs_fields), kind="numpy")),
-                                # ("rhs_rho_u", discr.convert_volume(
-                                #     op.rho_u(rhs_fields), kind="numpy")),
-                                ],
-                            expressions=[
-                                ("p", "(0.4)*(e- 0.5*(rho_u*u))"),
-                                ],
-                            time=t, step=step
-                            )
-                    visf.close()
-
-                fields = stepper(fields, t, dt, rhs)
-
-        finally:
-            vis.close()
-            logmgr.save()
-            discr.close()
-
-if __name__ == "__main__":
-    main()
diff --git a/unported-examples/gas_dynamics/euler/sine-wave.py b/unported-examples/gas_dynamics/euler/sine-wave.py
deleted file mode 100644
index e5fb0bca2cdfb21e722a2f3904059fec922b084c..0000000000000000000000000000000000000000
--- a/unported-examples/gas_dynamics/euler/sine-wave.py
+++ /dev/null
@@ -1,204 +0,0 @@
-__copyright__ = "Copyright (C) 2008 Andreas Kloeckner"
-
-__license__ = """
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-"""
-
-
-from __future__ import division
-from __future__ import absolute_import
-from __future__ import print_function
-import numpy
-import numpy.linalg as la
-
-
-
-
-class SineWave:
-    def __init__(self):
-        self.gamma = 1.4
-        self.mu = 0
-        self.prandtl = 0.72
-        self.spec_gas_const = 287.1
-
-    def __call__(self, t, x_vec):
-        rho = 2 + numpy.sin(x_vec[0] + x_vec[1] + x_vec[2] - 2 * t)
-        velocity = numpy.array([1, 1, 0])
-        p = 1
-        e = p/(self.gamma-1) + rho/2 * numpy.dot(velocity, velocity)
-        rho_u = rho * velocity[0]
-        rho_v = rho * velocity[1]
-        rho_w = rho * velocity[2]
-
-        from grudge.tools import join_fields
-        return join_fields(rho, e, rho_u, rho_v, rho_w)
-
-    def properties(self):
-        return(self.gamma, self.mu, self.prandtl, self.spec_gas_const)
-
-    def volume_interpolant(self, t, discr):
-        return discr.convert_volume(
-                        self(t, discr.nodes.T),
-                        kind=discr.compute_kind)
-
-    def boundary_interpolant(self, t, discr, tag):
-        return discr.convert_boundary(
-                        self(t, discr.get_boundary(tag).nodes.T),
-                         tag=tag, kind=discr.compute_kind)
-
-
-
-
-def main(final_time=1, write_output=False):
-    from grudge.backends import guess_run_context
-    rcon = guess_run_context()
-
-    from grudge.tools import EOCRecorder, to_obj_array
-    eoc_rec = EOCRecorder()
-
-    if rcon.is_head_rank:
-        from grudge.mesh import make_box_mesh
-        mesh = make_box_mesh((0,0,0), (10,10,10), max_volume=0.5)
-        mesh_data = rcon.distribute_mesh(mesh)
-    else:
-        mesh_data = rcon.receive_mesh()
-
-    for order in [3, 4, 5]:
-        discr = rcon.make_discretization(mesh_data, order=order,
-                        default_scalar_type=numpy.float64)
-
-        from grudge.visualization import SiloVisualizer, VtkVisualizer
-        vis = VtkVisualizer(discr, rcon, "sinewave-%d" % order)
-        #vis = SiloVisualizer(discr, rcon)
-
-        sinewave = SineWave()
-        fields = sinewave.volume_interpolant(0, discr)
-        gamma, mu, prandtl, spec_gas_const = sinewave.properties()
-
-        from grudge.mesh import BTAG_ALL
-        from grudge.models.gas_dynamics import GasDynamicsOperator
-        op = GasDynamicsOperator(dimensions=mesh.dimensions, gamma=gamma, mu=mu,
-                prandtl=prandtl, spec_gas_const=spec_gas_const,
-                bc_inflow=sinewave, bc_outflow=sinewave, bc_noslip=sinewave,
-                inflow_tag=BTAG_ALL, source=None)
-
-        euler_ex = op.bind(discr)
-
-        max_eigval = [0]
-        def rhs(t, q):
-            ode_rhs, speed = euler_ex(t, q)
-            max_eigval[0] = speed
-            return ode_rhs
-        rhs(0, fields)
-
-        if rcon.is_head_rank:
-            print("---------------------------------------------")
-            print("order %d" % order)
-            print("---------------------------------------------")
-            print("#elements=", len(mesh.elements))
-
-        from grudge.timestep import RK4TimeStepper
-        stepper = RK4TimeStepper()
-
-        # diagnostics setup ---------------------------------------------------
-        from logpyle import LogManager, add_general_quantities, \
-                add_simulation_quantities, add_run_info
-
-        if write_output:
-            log_name = ("euler-sinewave-%(order)d-%(els)d.dat"
-                    % {"order":order, "els":len(mesh.elements)})
-        else:
-            log_name = False
-        logmgr = LogManager(log_name, "w", rcon.communicator)
-        add_run_info(logmgr)
-        add_general_quantities(logmgr)
-        add_simulation_quantities(logmgr)
-        discr.add_instrumentation(logmgr)
-        stepper.add_instrumentation(logmgr)
-
-        logmgr.add_watches(["step.max", "t_sim.max", "t_step.max"])
-
-        # timestep loop -------------------------------------------------------
-        try:
-            from grudge.timestep import times_and_steps
-            step_it = times_and_steps(
-                    final_time=final_time, logmgr=logmgr,
-                    max_dt_getter=lambda t: op.estimate_timestep(discr,
-                        stepper=stepper, t=t, max_eigenvalue=max_eigval[0]))
-
-            for step, t, dt in step_it:
-                #if step % 10 == 0:
-                if write_output:
-                    visf = vis.make_file("sinewave-%d-%04d" % (order, step))
-
-                    #from pyvisfile.silo import DB_VARTYPE_VECTOR
-                    vis.add_data(visf,
-                            [
-                                ("rho", discr.convert_volume(op.rho(fields), kind="numpy")),
-                                ("e", discr.convert_volume(op.e(fields), kind="numpy")),
-                                ("rho_u", discr.convert_volume(op.rho_u(fields), kind="numpy")),
-                                ("u", discr.convert_volume(op.u(fields), kind="numpy")),
-
-                                #("true_rho", op.rho(true_fields)),
-                                #("true_e", op.e(true_fields)),
-                                #("true_rho_u", op.rho_u(true_fields)),
-                                #("true_u", op.u(true_fields)),
-
-                                #("rhs_rho", op.rho(rhs_fields)),
-                                #("rhs_e", op.e(rhs_fields)),
-                                #("rhs_rho_u", op.rho_u(rhs_fields)),
-                                ],
-                            #expressions=[
-                                #("diff_rho", "rho-true_rho"),
-                                #("diff_e", "e-true_e"),
-                                #("diff_rho_u", "rho_u-true_rho_u", DB_VARTYPE_VECTOR),
-
-                                #("p", "0.4*(e- 0.5*(rho_u*u))"),
-                                #],
-                            time=t, step=step
-                            )
-                    visf.close()
-
-                fields = stepper(fields, t, dt, rhs)
-
-        finally:
-            vis.close()
-            logmgr.close()
-            discr.close()
-
-        true_fields = sinewave.volume_interpolant(t, discr)
-        eoc_rec.add_data_point(order, discr.norm(fields-true_fields))
-        print()
-        print(eoc_rec.pretty_print("P.Deg.", "L2 Error"))
-
-
-
-
-if __name__ == "__main__":
-    main()
-
-
-
-
-# entry points for py.test ----------------------------------------------------
-from pytools.test import mark_test
-@mark_test.long
-def test_euler_sine_wave():
-    main(final_time=0.1, write_output=False)
-
diff --git a/unported-examples/gas_dynamics/euler/sod-2d.py b/unported-examples/gas_dynamics/euler/sod-2d.py
deleted file mode 100644
index 5e6ca49c100b0dfa4bcfcc274363be6e7c0b8c0d..0000000000000000000000000000000000000000
--- a/unported-examples/gas_dynamics/euler/sod-2d.py
+++ /dev/null
@@ -1,185 +0,0 @@
-from __future__ import division
-from __future__ import absolute_import
-from __future__ import print_function
-import numpy
-import numpy.linalg as la
-
-
-
-
-class Sod:
-    def __init__(self, gamma):
-        self.gamma = gamma
-        self.prandtl = 0.72
-
-    def __call__(self, t, x_vec):
-
-        from grudge.tools import heaviside
-        from grudge.tools import heaviside_a
-
-        x_rel = x_vec[0]
-        y_rel = x_vec[1]
-
-        from math import pi
-        r = numpy.sqrt(x_rel**2+y_rel**2)
-        r_shift=r-3.0
-        u = 0.0
-        v = 0.0
-        from numpy import sign
-        rho = heaviside(-r_shift)+.125*heaviside_a(r_shift,1.0)
-        e = (1.0/(self.gamma-1.0))*(heaviside(-r_shift)+.1*heaviside_a(r_shift,1.0))
-        p = (self.gamma-1.0)*e
-
-        from grudge.tools import join_fields
-        return join_fields(rho, e, rho*u, rho*v)
-
-
-    def volume_interpolant(self, t, discr):
-        return discr.convert_volume(
-                        self(t, discr.nodes.T),
-                        kind=discr.compute_kind)
-
-    def boundary_interpolant(self, t, discr, tag):
-        return discr.convert_boundary(
-                        self(t, discr.get_boundary(tag).nodes.T),
-                         tag=tag, kind=discr.compute_kind)
-
-
-
-
-def main():
-    from grudge.backends import guess_run_context
-    rcon = guess_run_context()
-
-    from grudge.tools import to_obj_array
-
-    if rcon.is_head_rank:
-        from grudge.mesh.generator import make_rect_mesh
-        mesh = make_rect_mesh((-5,-5), (5,5), max_area=0.01)
-        mesh_data = rcon.distribute_mesh(mesh)
-    else:
-        mesh_data = rcon.receive_mesh()
-
-    for order in [1]:
-        discr = rcon.make_discretization(mesh_data, order=order,
-                        default_scalar_type=numpy.float64)
-
-        from grudge.visualization import SiloVisualizer, VtkVisualizer
-        vis = VtkVisualizer(discr, rcon, "Sod2D-%d" % order)
-        #vis = SiloVisualizer(discr, rcon)
-
-        sod_field = Sod(gamma=1.4)
-        fields = sod_field.volume_interpolant(0, discr)
-
-        from grudge.models.gas_dynamics import GasDynamicsOperator
-        from grudge.mesh import BTAG_ALL
-        op = GasDynamicsOperator(dimensions=2, gamma=sod_field.gamma, mu=0.0,
-                prandtl=sod_field.prandtl,
-                bc_inflow=sod_field,
-                bc_outflow=sod_field,
-                bc_noslip=sod_field,
-                inflow_tag=BTAG_ALL,
-                source=None)
-
-        euler_ex = op.bind(discr)
-
-        max_eigval = [0]
-        def rhs(t, q):
-            ode_rhs, speed = euler_ex(t, q)
-            max_eigval[0] = speed
-            return ode_rhs
-        rhs(0, fields)
-
-        if rcon.is_head_rank:
-            print("---------------------------------------------")
-            print("order %d" % order)
-            print("---------------------------------------------")
-            print("#elements=", len(mesh.elements))
-
-        # limiter setup ------------------------------------------------------------
-        from grudge.models.gas_dynamics import SlopeLimiter1NEuler
-        limiter =  SlopeLimiter1NEuler(discr, sod_field.gamma, 2, op)
-
-        # integrator setup---------------------------------------------------------
-        from grudge.timestep import SSPRK3TimeStepper, RK4TimeStepper
-        stepper = SSPRK3TimeStepper(limiter=limiter)
-        #stepper = SSPRK3TimeStepper()
-        #stepper = RK4TimeStepper()
-
-        # diagnostics setup ---------------------------------------------------
-        from logpyle import LogManager, add_general_quantities, \
-                add_simulation_quantities, add_run_info
-
-        logmgr = LogManager("euler-%d.dat" % order, "w", rcon.communicator)
-        add_run_info(logmgr)
-        add_general_quantities(logmgr)
-        add_simulation_quantities(logmgr)
-        discr.add_instrumentation(logmgr)
-        stepper.add_instrumentation(logmgr)
-
-        logmgr.add_watches(["step.max", "t_sim.max", "t_step.max"])
-
-        # filter setup-------------------------------------------------------------
-        from grudge.discretization import Filter, ExponentialFilterResponseFunction
-        mode_filter = Filter(discr,
-                ExponentialFilterResponseFunction(min_amplification=0.9,order=4))
-
-        # timestep loop -------------------------------------------------------
-        try:
-            from grudge.timestep import times_and_steps
-            step_it = times_and_steps(
-                    final_time=1.0, logmgr=logmgr,
-                    max_dt_getter=lambda t: op.estimate_timestep(discr,
-                        stepper=stepper, t=t, max_eigenvalue=max_eigval[0]))
-
-            for step, t, dt in step_it:
-                if step % 5 == 0:
-                #if False:
-                    visf = vis.make_file("vortex-%d-%04d" % (order, step))
-
-                    #true_fields = vortex.volume_interpolant(t, discr)
-
-                    #from pyvisfile.silo import DB_VARTYPE_VECTOR
-                    vis.add_data(visf,
-                            [
-                                ("rho", discr.convert_volume(op.rho(fields), kind="numpy")),
-                                ("e", discr.convert_volume(op.e(fields), kind="numpy")),
-                                ("rho_u", discr.convert_volume(op.rho_u(fields), kind="numpy")),
-                                ("u", discr.convert_volume(op.u(fields), kind="numpy")),
-
-                                #("true_rho", op.rho(true_fields)),
-                                #("true_e", op.e(true_fields)),
-                                #("true_rho_u", op.rho_u(true_fields)),
-                                #("true_u", op.u(true_fields)),
-
-                                #("rhs_rho", op.rho(rhs_fields)),
-                                #("rhs_e", op.e(rhs_fields)),
-                                #("rhs_rho_u", op.rho_u(rhs_fields)),
-                                ],
-                            #expressions=[
-                                #("diff_rho", "rho-true_rho"),
-                                #("diff_e", "e-true_e"),
-                                #("diff_rho_u", "rho_u-true_rho_u", DB_VARTYPE_VECTOR),
-
-                                #("p", "0.4*(e- 0.5*(rho_u*u))"),
-                                #],
-                            time=t, step=step
-                            )
-                    visf.close()
-
-                fields = stepper(fields, t, dt, rhs)
-                # fields = limiter(fields)
-                # fields = mode_filter(fields)
-
-                assert not numpy.isnan(numpy.sum(fields[0]))
-        finally:
-            vis.close()
-            logmgr.close()
-            discr.close()
-
-        # not solution, just to check against when making code changes
-        true_fields = sod_field.volume_interpolant(t, discr)
-        print(discr.norm(fields-true_fields))
-
-if __name__ == "__main__":
-    main()
diff --git a/unported-examples/gas_dynamics/euler/vortex-adaptive-grid.py b/unported-examples/gas_dynamics/euler/vortex-adaptive-grid.py
deleted file mode 100644
index 6faeafad6cca4b369bcfe37577532365db6a5af3..0000000000000000000000000000000000000000
--- a/unported-examples/gas_dynamics/euler/vortex-adaptive-grid.py
+++ /dev/null
@@ -1,265 +0,0 @@
-__copyright__ = "Copyright (C) 2008 Andreas Kloeckner"
-
-__license__ = """
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-"""
-
-
-
-from __future__ import division
-from __future__ import absolute_import
-from __future__ import print_function
-import numpy
-import numpy.linalg as la
-
-
-
-
-def main(write_output=True):
-    from pytools import add_python_path_relative_to_script
-    add_python_path_relative_to_script("..")
-
-    from grudge.backends import guess_run_context
-    rcon = guess_run_context()
-
-    from grudge.tools import EOCRecorder
-    eoc_rec = EOCRecorder()
-
-
-    if rcon.is_head_rank:
-        from grudge.mesh.generator import \
-                make_rect_mesh, \
-                make_centered_regular_rect_mesh
-
-        refine = 4
-        mesh = make_centered_regular_rect_mesh((0,-5), (10,5), n=(9,9),
-                post_refine_factor=refine)
-        mesh_data = rcon.distribute_mesh(mesh)
-    else:
-        mesh_data = rcon.receive_mesh()
-
-    # a second mesh to regrid to
-    if rcon.is_head_rank:
-        from grudge.mesh.generator import \
-                make_rect_mesh, \
-                make_centered_regular_rect_mesh
-
-        refine = 4
-        mesh2 = make_centered_regular_rect_mesh((0,-5), (10,5), n=(8,8),
-                post_refine_factor=refine)
-        mesh_data2 = rcon.distribute_mesh(mesh2)
-    else:
-        mesh_data2 = rcon.receive_mesh()
-
-
-
-    for order in [3,4]:
-        discr = rcon.make_discretization(mesh_data, order=order,
-                        default_scalar_type=numpy.float64,
-                        quad_min_degrees={
-                            "gasdyn_vol": 3*order,
-                            "gasdyn_face": 3*order,
-                            })
-
-        discr2 = rcon.make_discretization(mesh_data2, order=order,
-                        default_scalar_type=numpy.float64,
-                        quad_min_degrees={
-                            "gasdyn_vol": 3*order,
-                            "gasdyn_face": 3*order,
-                            })
-
-
-        from grudge.visualization import SiloVisualizer, VtkVisualizer
-        vis = VtkVisualizer(discr, rcon, "vortex-%d" % order)
-        #vis = SiloVisualizer(discr, rcon)
-
-        from gas_dynamics_initials import Vortex
-        vortex = Vortex()
-        fields = vortex.volume_interpolant(0, discr)
-
-        from grudge.models.gas_dynamics import GasDynamicsOperator
-        from grudge.mesh import BTAG_ALL
-
-        op = GasDynamicsOperator(dimensions=2, gamma=vortex.gamma, mu=vortex.mu,
-                prandtl=vortex.prandtl, spec_gas_const=vortex.spec_gas_const,
-                bc_inflow=vortex, bc_outflow=vortex, bc_noslip=vortex,
-                inflow_tag=BTAG_ALL, source=None)
-
-        euler_ex = op.bind(discr)
-
-        max_eigval = [0]
-        def rhs(t, q):
-            ode_rhs, speed = euler_ex(t, q)
-            max_eigval[0] = speed
-            return ode_rhs
-        rhs(0, fields)
-
-
-        if rcon.is_head_rank:
-            print("---------------------------------------------")
-            print("order %d" % order)
-            print("---------------------------------------------")
-            print("#elements for mesh 1 =", len(mesh.elements))
-            print("#elements for mesh 2 =", len(mesh2.elements))
-
-
-        # limiter ------------------------------------------------------------
-        from grudge.models.gas_dynamics import SlopeLimiter1NEuler
-        limiter = SlopeLimiter1NEuler(discr, vortex.gamma, 2, op)
-
-        from grudge.timestep import SSPRK3TimeStepper
-        #stepper = SSPRK3TimeStepper(limiter=limiter)
-        stepper = SSPRK3TimeStepper()
-
-        #from grudge.timestep import RK4TimeStepper
-        #stepper = RK4TimeStepper()
-
-        # diagnostics setup ---------------------------------------------------
-        from logpyle import LogManager, add_general_quantities, \
-                add_simulation_quantities, add_run_info
-
-        if write_output:
-            log_file_name = "euler-%d.dat" % order
-        else:
-            log_file_name = None
-
-        logmgr = LogManager(log_file_name, "w", rcon.communicator)
-        add_run_info(logmgr)
-        add_general_quantities(logmgr)
-        add_simulation_quantities(logmgr)
-        discr.add_instrumentation(logmgr)
-        stepper.add_instrumentation(logmgr)
-
-        logmgr.add_watches(["step.max", "t_sim.max", "t_step.max"])
-
-        # timestep loop -------------------------------------------------------
-        try:
-            final_time = 0.2
-            from grudge.timestep import times_and_steps
-            step_it = times_and_steps(
-                    final_time=final_time, logmgr=logmgr,
-                    max_dt_getter=lambda t: op.estimate_timestep(discr,
-                        stepper=stepper, t=t, max_eigenvalue=max_eigval[0]))
-
-            for step, t, dt in step_it:
-                if step % 10 == 0 and write_output:
-                #if False:
-                    visf = vis.make_file("vortex-%d-%04d" % (order, step))
-
-                    #true_fields = vortex.volume_interpolant(t, discr)
-
-                    from pyvisfile.silo import DB_VARTYPE_VECTOR
-                    vis.add_data(visf,
-                            [
-                                ("rho", discr.convert_volume(op.rho(fields), kind="numpy")),
-                                ("e", discr.convert_volume(op.e(fields), kind="numpy")),
-                                ("rho_u", discr.convert_volume(op.rho_u(fields), kind="numpy")),
-                                ("u", discr.convert_volume(op.u(fields), kind="numpy")),
-
-                                #("true_rho", discr.convert_volume(op.rho(true_fields), kind="numpy")),
-                                #("true_e", discr.convert_volume(op.e(true_fields), kind="numpy")),
-                                #("true_rho_u", discr.convert_volume(op.rho_u(true_fields), kind="numpy")),
-                                #("true_u", discr.convert_volume(op.u(true_fields), kind="numpy")),
-
-                                #("rhs_rho", discr.convert_volume(op.rho(rhs_fields), kind="numpy")),
-                                #("rhs_e", discr.convert_volume(op.e(rhs_fields), kind="numpy")),
-                                #("rhs_rho_u", discr.convert_volume(op.rho_u(rhs_fields), kind="numpy")),
-                                ],
-                            #expressions=[
-                                #("diff_rho", "rho-true_rho"),
-                                #("diff_e", "e-true_e"),
-                                #("diff_rho_u", "rho_u-true_rho_u", DB_VARTYPE_VECTOR),
-
-                                #("p", "0.4*(e- 0.5*(rho_u*u))"),
-                                #],
-                            time=t, step=step
-                            )
-                    visf.close()
-
-                fields = stepper(fields, t, dt, rhs)
-                #fields = limiter(fields)
-
-                #regrid to discr2 at some arbitrary time
-                if step == 21:
-
-                    #get interpolated fields
-                    fields = discr.get_regrid_values(fields, discr2, dtype=None, use_btree=True, thresh=1e-8)
-                    #get new stepper (old one has reference to discr
-                    stepper = SSPRK3TimeStepper()
-                    #new bind
-                    euler_ex = op.bind(discr2)
-                    #new rhs
-                    max_eigval = [0]
-                    def rhs(t, q):
-                        ode_rhs, speed = euler_ex(t, q)
-                        max_eigval[0] = speed
-                        return ode_rhs
-                    rhs(t+dt, fields)
-                    #add logmanager
-                    #discr2.add_instrumentation(logmgr)
-                    #new step_it
-                    step_it = times_and_steps(
-                        final_time=final_time, logmgr=logmgr,
-                        max_dt_getter=lambda t: op.estimate_timestep(discr2,
-                            stepper=stepper, t=t, max_eigenvalue=max_eigval[0]))
-
-                    #new visualization
-                    vis.close()
-                    vis = VtkVisualizer(discr2, rcon, "vortexNewGrid-%d" % order)
-                    discr=discr2
-
-
-
-                assert not numpy.isnan(numpy.sum(fields[0]))
-
-            true_fields = vortex.volume_interpolant(final_time, discr)
-            l2_error = discr.norm(fields-true_fields)
-            l2_error_rho = discr.norm(op.rho(fields)-op.rho(true_fields))
-            l2_error_e = discr.norm(op.e(fields)-op.e(true_fields))
-            l2_error_rhou = discr.norm(op.rho_u(fields)-op.rho_u(true_fields))
-            l2_error_u = discr.norm(op.u(fields)-op.u(true_fields))
-
-            eoc_rec.add_data_point(order, l2_error)
-            print()
-            print(eoc_rec.pretty_print("P.Deg.", "L2 Error"))
-
-            logmgr.set_constant("l2_error", l2_error)
-            logmgr.set_constant("l2_error_rho", l2_error_rho)
-            logmgr.set_constant("l2_error_e", l2_error_e)
-            logmgr.set_constant("l2_error_rhou", l2_error_rhou)
-            logmgr.set_constant("l2_error_u", l2_error_u)
-            logmgr.set_constant("refinement", refine)
-
-        finally:
-            if write_output:
-                vis.close()
-
-            logmgr.close()
-            discr.close()
-
-
-
-    # after order loop
-    # assert eoc_rec.estimate_order_of_convergence()[0,1] > 6
-
-
-
-
-if __name__ == "__main__":
-    main()
diff --git a/unported-examples/gas_dynamics/euler/vortex-sources.py b/unported-examples/gas_dynamics/euler/vortex-sources.py
deleted file mode 100644
index a0f466f8a4dc87793514f3f6ac8428f8337d49e4..0000000000000000000000000000000000000000
--- a/unported-examples/gas_dynamics/euler/vortex-sources.py
+++ /dev/null
@@ -1,341 +0,0 @@
-__copyright__ = "Copyright (C) 2008 Andreas Kloeckner"
-
-__license__ = """
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-"""
-
-
-
-from __future__ import division
-from __future__ import absolute_import
-from __future__ import print_function
-import numpy
-import numpy.linalg as la
-
-# modifies isentropic vortex solution so that rho->Arho, P->A^gamma rho^gamma
-# this will be analytic solution if appropriate source terms are added
-# to the RHS. coded for vel_x=1, vel_y=0
-
-
-class Vortex:
-    def __init__(self, beta, gamma, center, velocity, densityA):
-        self.beta = beta
-        self.gamma = gamma
-        self.center = numpy.asarray(center)
-        self.velocity = numpy.asarray(velocity)
-        self.densityA = densityA
-
-    def __call__(self, t, x_vec):
-        vortex_loc = self.center + t*self.velocity
-
-        # coordinates relative to vortex center
-        x_rel = x_vec[0] - vortex_loc[0]
-        y_rel = x_vec[1] - vortex_loc[1]
-
-        # Y.C. Zhou, G.W. Wei / Journal of Computational Physics 189 (2003) 159
-        # also JSH/TW Nodal DG Methods, p. 209
-
-        from math import pi
-        r = numpy.sqrt(x_rel**2+y_rel**2)
-        expterm = self.beta*numpy.exp(1-r**2)
-        u = self.velocity[0] - expterm*y_rel/(2*pi)
-        v = self.velocity[1] + expterm*x_rel/(2*pi)
-        rho = self.densityA*(1-(self.gamma-1)/(16*self.gamma*pi**2)*expterm**2)**(1/(self.gamma-1))
-        p = rho**self.gamma
-
-        e = p/(self.gamma-1) + rho/2*(u**2+v**2)
-
-        from grudge.tools import join_fields
-        return join_fields(rho, e, rho*u, rho*v)
-
-    def volume_interpolant(self, t, discr):
-        return discr.convert_volume(
-                        self(t, discr.nodes.T
-                            .astype(discr.default_scalar_type)),
-                        kind=discr.compute_kind)
-
-    def boundary_interpolant(self, t, discr, tag):
-        return discr.convert_boundary(
-                        self(t, discr.get_boundary(tag).nodes.T
-                            .astype(discr.default_scalar_type)),
-                         tag=tag, kind=discr.compute_kind)
-
-
-
-class SourceTerms:
-    def __init__(self, beta, gamma, center, velocity, densityA):
-        self.beta = beta
-        self.gamma = gamma
-        self.center = numpy.asarray(center)
-        self.velocity = numpy.asarray(velocity)
-        self.densityA = densityA
-
-
-
-    def __call__(self,t,x_vec,q):
-
-        vortex_loc = self.center + t*self.velocity
-
-        # coordinates relative to vortex center
-        x_rel = x_vec[0] - vortex_loc[0]
-        y_rel = x_vec[1] - vortex_loc[1]
-
-        # sources written in terms of A=1.0 solution
-        # (standard isentropic vortex)
-
-        from math import pi
-        r = numpy.sqrt(x_rel**2+y_rel**2)
-        expterm = self.beta*numpy.exp(1-r**2)
-        u = self.velocity[0] - expterm*y_rel/(2*pi)
-        v = self.velocity[1] + expterm*x_rel/(2*pi)
-        rho = (1-(self.gamma-1)/(16*self.gamma*pi**2)*expterm**2)**(1/(self.gamma-1))
-        p = rho**self.gamma
-
-        #computed necessary derivatives
-        expterm_t = 2*expterm*x_rel
-        expterm_x = -2*expterm*x_rel
-        expterm_y = -2*expterm*y_rel
-        u_x = -expterm*y_rel/(2*pi)*(-2*x_rel)
-        v_y = expterm*x_rel/(2*pi)*(-2*y_rel)
-
-        #derivatives for rho (A=1)
-        facG=self.gamma-1
-        rho_t = (1/facG)*(1-(facG)/(16*self.gamma*pi**2)*expterm**2)**(1/facG-1)* \
-                (-facG/(16*self.gamma*pi**2)*2*expterm*expterm_t)
-        rho_x = (1/facG)*(1-(facG)/(16*self.gamma*pi**2)*expterm**2)**(1/facG-1)* \
-                (-facG/(16*self.gamma*pi**2)*2*expterm*expterm_x)
-        rho_y = (1/facG)*(1-(facG)/(16*self.gamma*pi**2)*expterm**2)**(1/facG-1)* \
-                (-facG/(16*self.gamma*pi**2)*2*expterm*expterm_y)
-
-        #derivatives for rho (A=1) to the power of gamma
-        rho_gamma_t = self.gamma*rho**(self.gamma-1)*rho_t
-        rho_gamma_x = self.gamma*rho**(self.gamma-1)*rho_x
-        rho_gamma_y = self.gamma*rho**(self.gamma-1)*rho_y
-
-
-        factorA=self.densityA**self.gamma-self.densityA
-        #construct source terms
-        source_rho = x_vec[0]-x_vec[0]
-        source_e = (factorA/(self.gamma-1))*(rho_gamma_t + self.gamma*(u_x*rho**self.gamma+u*rho_gamma_x)+ \
-                self.gamma*(v_y*rho**self.gamma+v*rho_gamma_y))
-        source_rhou = factorA*rho_gamma_x
-        source_rhov = factorA*rho_gamma_y
-
-        from grudge.tools import join_fields
-        return join_fields(source_rho, source_e, source_rhou, source_rhov, x_vec[0]-x_vec[0])
-
-    def volume_interpolant(self,t,q,discr):
-        return discr.convert_volume(
-                self(t,discr.nodes.T,q),
-                kind=discr.compute_kind)
-
-
-def main(write_output=True):
-    from grudge.backends import guess_run_context
-    rcon = guess_run_context(
-                    #["cuda"]
-                    )
-
-    gamma = 1.4
-
-    # at A=1 we have case of isentropic vortex, source terms
-    # arise for other values
-    densityA = 2.0
-
-    from grudge.tools import EOCRecorder, to_obj_array
-    eoc_rec = EOCRecorder()
-
-    if rcon.is_head_rank:
-        from grudge.mesh import \
-                make_rect_mesh, \
-                make_centered_regular_rect_mesh
-
-        refine = 1
-        mesh = make_centered_regular_rect_mesh((0,-5), (10,5), n=(9,9),
-                post_refine_factor=refine)
-        mesh_data = rcon.distribute_mesh(mesh)
-    else:
-        mesh_data = rcon.receive_mesh()
-
-    for order in [4,5]:
-        discr = rcon.make_discretization(mesh_data, order=order,
-                        debug=[#"cuda_no_plan",
-                        #"print_op_code"
-                        ],
-                        default_scalar_type=numpy.float64)
-
-        from grudge.visualization import SiloVisualizer, VtkVisualizer
-        #vis = VtkVisualizer(discr, rcon, "vortex-%d" % order)
-        vis = SiloVisualizer(discr, rcon)
-
-        vortex = Vortex(beta=5, gamma=gamma,
-                center=[5,0],
-                velocity=[1,0], densityA=densityA)
-        fields = vortex.volume_interpolant(0, discr)
-        sources=SourceTerms(beta=5, gamma=gamma,
-                center=[5,0],
-                velocity=[1,0], densityA=densityA)
-
-        from grudge.models.gas_dynamics import (
-                GasDynamicsOperator, GammaLawEOS)
-        from grudge.mesh import BTAG_ALL
-
-        op = GasDynamicsOperator(dimensions=2,
-                mu=0.0, prandtl=0.72, spec_gas_const=287.1,
-                equation_of_state=GammaLawEOS(vortex.gamma),
-                bc_inflow=vortex, bc_outflow=vortex, bc_noslip=vortex,
-                inflow_tag=BTAG_ALL, source=sources)
-
-        euler_ex = op.bind(discr)
-
-        max_eigval = [0]
-        def rhs(t, q):
-            ode_rhs, speed = euler_ex(t, q)
-            max_eigval[0] = speed
-            return ode_rhs
-        rhs(0, fields)
-
-        if rcon.is_head_rank:
-            print("---------------------------------------------")
-            print("order %d" % order)
-            print("---------------------------------------------")
-            print("#elements=", len(mesh.elements))
-
-        # limiter setup -------------------------------------------------------
-        from grudge.models.gas_dynamics import SlopeLimiter1NEuler
-        limiter = SlopeLimiter1NEuler(discr, gamma, 2, op)
-
-        # time stepper --------------------------------------------------------
-        from grudge.timestep import SSPRK3TimeStepper, RK4TimeStepper
-        #stepper = SSPRK3TimeStepper(limiter=limiter)
-        #stepper = SSPRK3TimeStepper()
-        stepper = RK4TimeStepper()
-
-        # diagnostics setup ---------------------------------------------------
-        from logpyle import LogManager, add_general_quantities, \
-                add_simulation_quantities, add_run_info
-
-        if write_output:
-            log_file_name = "euler-%d.dat" % order
-        else:
-            log_file_name = None
-
-        logmgr = LogManager(log_file_name, "w", rcon.communicator)
-        add_run_info(logmgr)
-        add_general_quantities(logmgr)
-        add_simulation_quantities(logmgr)
-        discr.add_instrumentation(logmgr)
-        stepper.add_instrumentation(logmgr)
-
-        logmgr.add_watches(["step.max", "t_sim.max", "t_step.max"])
-
-        # timestep loop -------------------------------------------------------
-        t = 0
-
-        #fields = limiter(fields)
-
-        try:
-            from grudge.timestep import times_and_steps
-            step_it = times_and_steps(
-                    final_time=.1,
-                    #max_steps=500,
-                    logmgr=logmgr,
-                    max_dt_getter=lambda t: 0.4*op.estimate_timestep(discr,
-                        stepper=stepper, t=t, max_eigenvalue=max_eigval[0]))
-
-            for step, t, dt in step_it:
-                if step % 1 == 0 and write_output:
-                #if False:
-                    visf = vis.make_file("vortex-%d-%04d" % (order, step))
-
-                    true_fields = vortex.volume_interpolant(t, discr)
-
-                    #rhs_fields = rhs(t, fields)
-
-                    from pyvisfile.silo import DB_VARTYPE_VECTOR
-                    vis.add_data(visf,
-                            [
-                                ("rho", discr.convert_volume(op.rho(fields), kind="numpy")),
-                                ("e", discr.convert_volume(op.e(fields), kind="numpy")),
-                                ("rho_u", discr.convert_volume(op.rho_u(fields), kind="numpy")),
-                                ("u", discr.convert_volume(op.u(fields), kind="numpy")),
-
-                                #("true_rho", discr.convert_volume(op.rho(true_fields), kind="numpy")),
-                                #("true_e", discr.convert_volume(op.e(true_fields), kind="numpy")),
-                                #("true_rho_u", discr.convert_volume(op.rho_u(true_fields), kind="numpy")),
-                                #("true_u", discr.convert_volume(op.u(true_fields), kind="numpy")),
-
-                                #("rhs_rho", discr.convert_volume(op.rho(rhs_fields), kind="numpy")),
-                                #("rhs_e", discr.convert_volume(op.e(rhs_fields), kind="numpy")),
-                                #("rhs_rho_u", discr.convert_volume(op.rho_u(rhs_fields), kind="numpy")),
-                                ],
-                            expressions=[
-                                #("diff_rho", "rho-true_rho"),
-                                #("diff_e", "e-true_e"),
-                                #("diff_rho_u", "rho_u-true_rho_u", DB_VARTYPE_VECTOR),
-
-                                ("p", "0.4*(e- 0.5*(rho_u*u))"),
-                                ],
-                            time=t, step=step
-                            )
-                    visf.close()
-
-                fields = stepper(fields, t, dt, rhs)
-
-            true_fields = vortex.volume_interpolant(t, discr)
-            l2_error = discr.norm(fields-true_fields)
-            l2_error_rho = discr.norm(op.rho(fields)-op.rho(true_fields))
-            l2_error_e = discr.norm(op.e(fields)-op.e(true_fields))
-            l2_error_rhou = discr.norm(op.rho_u(fields)-op.rho_u(true_fields))
-            l2_error_u = discr.norm(op.u(fields)-op.u(true_fields))
-
-            eoc_rec.add_data_point(order, l2_error_rho)
-            print()
-            print(eoc_rec.pretty_print("P.Deg.", "L2 Error"))
-
-            logmgr.set_constant("l2_error", l2_error)
-            logmgr.set_constant("l2_error_rho", l2_error_rho)
-            logmgr.set_constant("l2_error_e", l2_error_e)
-            logmgr.set_constant("l2_error_rhou", l2_error_rhou)
-            logmgr.set_constant("l2_error_u", l2_error_u)
-            logmgr.set_constant("refinement", refine)
-
-        finally:
-            if write_output:
-                vis.close()
-
-            logmgr.close()
-            discr.close()
-
-    # after order loop
-    #assert eoc_rec.estimate_order_of_convergence()[0,1] > 6
-
-
-
-
-if __name__ == "__main__":
-    main()
-
-
-
-# entry points for py.test ----------------------------------------------------
-from pytools.test import mark_test
-@mark_test.long
-def test_euler_vortex():
-    main(write_output=False)
diff --git a/unported-examples/gas_dynamics/euler/vortex.py b/unported-examples/gas_dynamics/euler/vortex.py
deleted file mode 100644
index 2129f88272f5421dd576e211a1d57ed8bc09ca35..0000000000000000000000000000000000000000
--- a/unported-examples/gas_dynamics/euler/vortex.py
+++ /dev/null
@@ -1,220 +0,0 @@
-__copyright__ = "Copyright (C) 2008 Andreas Kloeckner"
-
-__license__ = """
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-"""
-
-
-from __future__ import division
-from __future__ import absolute_import
-from __future__ import print_function
-import numpy
-
-
-
-
-def main(write_output=True):
-    from pytools import add_python_path_relative_to_script
-    add_python_path_relative_to_script("..")
-
-    from grudge.backends import guess_run_context
-    rcon = guess_run_context()
-
-    from grudge.tools import EOCRecorder
-    eoc_rec = EOCRecorder()
-
-    if rcon.is_head_rank:
-        from grudge.mesh.generator import \
-                make_rect_mesh, \
-                make_centered_regular_rect_mesh
-
-        refine = 4
-        mesh = make_centered_regular_rect_mesh((0,-5), (10,5), n=(9,9),
-                post_refine_factor=refine)
-        mesh_data = rcon.distribute_mesh(mesh)
-    else:
-        mesh_data = rcon.receive_mesh()
-
-    for order in [3, 4, 5]:
-        from gas_dynamics_initials import Vortex
-        flow = Vortex()
-
-        from grudge.models.gas_dynamics import (
-                GasDynamicsOperator, PolytropeEOS, GammaLawEOS)
-
-        from grudge.mesh import BTAG_ALL
-        # works equally well for GammaLawEOS
-        op = GasDynamicsOperator(dimensions=2, mu=flow.mu,
-                prandtl=flow.prandtl, spec_gas_const=flow.spec_gas_const,
-                equation_of_state=PolytropeEOS(flow.gamma),
-                bc_inflow=flow, bc_outflow=flow, bc_noslip=flow,
-                inflow_tag=BTAG_ALL, source=None)
-
-        discr = rcon.make_discretization(mesh_data, order=order,
-                        default_scalar_type=numpy.float64,
-                        quad_min_degrees={
-                            "gasdyn_vol": 3*order,
-                            "gasdyn_face": 3*order,
-                            },
-                        tune_for=op.sym_operator(),
-                        debug=["cuda_no_plan"])
-
-        from grudge.visualization import SiloVisualizer, VtkVisualizer
-        vis = VtkVisualizer(discr, rcon, "vortex-%d" % order)
-        #vis = SiloVisualizer(discr, rcon)
-
-        fields = flow.volume_interpolant(0, discr)
-
-        euler_ex = op.bind(discr)
-
-        max_eigval = [0]
-        def rhs(t, q):
-            ode_rhs, speed = euler_ex(t, q)
-            max_eigval[0] = speed
-            return ode_rhs
-        rhs(0, fields)
-
-        if rcon.is_head_rank:
-            print("---------------------------------------------")
-            print("order %d" % order)
-            print("---------------------------------------------")
-            print("#elements=", len(mesh.elements))
-
-
-        # limiter ------------------------------------------------------------
-        from grudge.models.gas_dynamics import SlopeLimiter1NEuler
-        limiter = SlopeLimiter1NEuler(discr, flow.gamma, 2, op)
-
-        from grudge.timestep.runge_kutta import SSP3TimeStepper
-        #stepper = SSP3TimeStepper(limiter=limiter)
-        stepper = SSP3TimeStepper(
-                vector_primitive_factory=discr.get_vector_primitive_factory())
-
-        #from grudge.timestep import RK4TimeStepper
-        #stepper = RK4TimeStepper()
-
-        # diagnostics setup ---------------------------------------------------
-        from logpyle import LogManager, add_general_quantities, \
-                add_simulation_quantities, add_run_info
-
-        if write_output:
-            log_file_name = "euler-%d.dat" % order
-        else:
-            log_file_name = None
-
-        logmgr = LogManager(log_file_name, "w", rcon.communicator)
-        add_run_info(logmgr)
-        add_general_quantities(logmgr)
-        add_simulation_quantities(logmgr)
-        discr.add_instrumentation(logmgr)
-        stepper.add_instrumentation(logmgr)
-
-        logmgr.add_watches(["step.max", "t_sim.max", "t_step.max"])
-
-        # timestep loop -------------------------------------------------------
-        try:
-            final_time = flow.final_time
-            from grudge.timestep import times_and_steps
-            step_it = times_and_steps(
-                    final_time=final_time, logmgr=logmgr,
-                    max_dt_getter=lambda t: op.estimate_timestep(discr,
-                        stepper=stepper, t=t, max_eigenvalue=max_eigval[0]))
-
-            print("run until t=%g" % final_time)
-            for step, t, dt in step_it:
-                if step % 10 == 0 and write_output:
-                #if False:
-                    visf = vis.make_file("vortex-%d-%04d" % (order, step))
-
-                    #true_fields = vortex.volume_interpolant(t, discr)
-
-                    from pyvisfile.silo import DB_VARTYPE_VECTOR
-                    vis.add_data(visf,
-                            [
-                                ("rho", discr.convert_volume(op.rho(fields), kind="numpy")),
-                                ("e", discr.convert_volume(op.e(fields), kind="numpy")),
-                                ("rho_u", discr.convert_volume(op.rho_u(fields), kind="numpy")),
-                                ("u", discr.convert_volume(op.u(fields), kind="numpy")),
-
-                                #("true_rho", discr.convert_volume(op.rho(true_fields), kind="numpy")),
-                                #("true_e", discr.convert_volume(op.e(true_fields), kind="numpy")),
-                                #("true_rho_u", discr.convert_volume(op.rho_u(true_fields), kind="numpy")),
-                                #("true_u", discr.convert_volume(op.u(true_fields), kind="numpy")),
-
-                                #("rhs_rho", discr.convert_volume(op.rho(rhs_fields), kind="numpy")),
-                                #("rhs_e", discr.convert_volume(op.e(rhs_fields), kind="numpy")),
-                                #("rhs_rho_u", discr.convert_volume(op.rho_u(rhs_fields), kind="numpy")),
-                                ],
-                            #expressions=[
-                                #("diff_rho", "rho-true_rho"),
-                                #("diff_e", "e-true_e"),
-                                #("diff_rho_u", "rho_u-true_rho_u", DB_VARTYPE_VECTOR),
-
-                                #("p", "0.4*(e- 0.5*(rho_u*u))"),
-                                #],
-                            time=t, step=step
-                            )
-                    visf.close()
-
-                fields = stepper(fields, t, dt, rhs)
-                #fields = limiter(fields)
-
-                assert not numpy.isnan(numpy.sum(fields[0]))
-
-            true_fields = flow.volume_interpolant(final_time, discr)
-            l2_error = discr.norm(fields-true_fields)
-            l2_error_rho = discr.norm(op.rho(fields)-op.rho(true_fields))
-            l2_error_e = discr.norm(op.e(fields)-op.e(true_fields))
-            l2_error_rhou = discr.norm(op.rho_u(fields)-op.rho_u(true_fields))
-            l2_error_u = discr.norm(op.u(fields)-op.u(true_fields))
-
-            eoc_rec.add_data_point(order, l2_error)
-            print()
-            print(eoc_rec.pretty_print("P.Deg.", "L2 Error"))
-
-            logmgr.set_constant("l2_error", l2_error)
-            logmgr.set_constant("l2_error_rho", l2_error_rho)
-            logmgr.set_constant("l2_error_e", l2_error_e)
-            logmgr.set_constant("l2_error_rhou", l2_error_rhou)
-            logmgr.set_constant("l2_error_u", l2_error_u)
-            logmgr.set_constant("refinement", refine)
-
-        finally:
-            if write_output:
-                vis.close()
-
-            logmgr.close()
-            discr.close()
-
-    # after order loop
-    assert eoc_rec.estimate_order_of_convergence()[0,1] > 6
-
-
-
-
-if __name__ == "__main__":
-    main()
-
-
-
-# entry points for py.test ----------------------------------------------------
-from pytools.test import mark_test
-@mark_test.long
-def test_euler_vortex():
-    main(write_output=False)
diff --git a/unported-examples/gas_dynamics/gas_dynamics_initials.py b/unported-examples/gas_dynamics/gas_dynamics_initials.py
deleted file mode 100644
index 48057a865fda5698982602899ac3a6100926fb38..0000000000000000000000000000000000000000
--- a/unported-examples/gas_dynamics/gas_dynamics_initials.py
+++ /dev/null
@@ -1,229 +0,0 @@
-__copyright__ = "Copyright (C) 2008 Andreas Kloeckner"
-
-__license__ = """
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-"""
-
-
-from __future__ import division
-from __future__ import absolute_import
-import numpy
-import numpy.linalg as la
-from six.moves import range
-
-
-
-
-class UniformMachFlow:
-    def __init__(self, mach=0.1, p=1, rho=1, reynolds=100,
-            gamma=1.4, prandtl=0.72, char_length=1, spec_gas_const=287.1,
-            angle_of_attack=None, direction=None, gaussian_pulse_at=None,
-            pulse_magnitude=0.1):
-        """
-        :param direction: is a vector indicating the direction of the
-          flow. Only one of angle_of_attack and direction may be
-          specified. Only the direction, not the magnitude, of
-          direction is taken into account.
-
-        :param angle_of_attack: if not None, specifies the angle of
-          the flow along the Y axis, where the flow is
-          directed along the X axis.
-        """
-        if angle_of_attack is not None and direction is not None:
-            raise ValueError("Only one of angle_of_attack and "
-                    "direction may be specified.")
-
-        if angle_of_attack is None and direction is None:
-            angle_of_attack = 0
-
-        if direction is not None:
-            self.direction = direction/la.norm(direction)
-        else:
-            self.direction = None
-
-        self.mach = mach
-        self.p = p
-        self.rho = rho
-
-        self.gamma = gamma
-        self.prandtl = prandtl
-        self.reynolds = reynolds
-        self.length = char_length
-        self.spec_gas_const = spec_gas_const
-
-        self.angle_of_attack = angle_of_attack
-
-        self.gaussian_pulse_at = gaussian_pulse_at
-        self.pulse_magnitude = pulse_magnitude
-
-        self.c = (self.gamma * p / rho)**0.5
-        u = self.velocity = mach * self.c
-        self.e = p / (self.gamma - 1) + rho / 2 * u**2
-
-        if numpy.isinf(self.reynolds):
-            self.mu = 0
-        else:
-            self.mu = u * self.length * rho / self.reynolds
-
-    def direction_vector(self, dimensions):
-        # this must be done here because dimensions is not known above
-        if self.direction is None:
-            assert self.angle_of_attack is not None
-            direction = numpy.zeros(dimensions, dtype=numpy.float64)
-            direction[0] = numpy.cos(
-                    self.angle_of_attack / 180. * numpy.pi)
-            direction[1] = numpy.sin(
-                    self.angle_of_attack / 180. * numpy.pi)
-            return direction
-        else:
-            return self.direction
-
-    def __call__(self, t, x_vec):
-        ones = numpy.ones_like(x_vec[0])
-        rho_field = ones*self.rho
-
-        if self.gaussian_pulse_at is not None:
-            rel_to_pulse = [x_vec[i] - self.gaussian_pulse_at[i]
-                    for i in range(len(x_vec))]
-            rho_field +=  self.pulse_magnitude * self.rho * numpy.exp(
-                - sum(rtp_i**2 for rtp_i in rel_to_pulse)/2)
-
-        direction = self.direction_vector(x_vec.shape[0])
-
-        from grudge.tools import make_obj_array
-        u_field = make_obj_array([ones*self.velocity*dir_i
-            for dir_i in direction])
-
-        from grudge.tools import join_fields
-        return join_fields(rho_field, self.e*ones, self.rho*u_field)
-
-    def volume_interpolant(self, t, discr):
-        return discr.convert_volume(
-                        self(t, discr.nodes.T),
-                        kind=discr.compute_kind,
-                        dtype=discr.default_scalar_type)
-
-    def boundary_interpolant(self, t, discr, tag):
-        return discr.convert_boundary(
-                        self(t, discr.get_boundary(tag).nodes.T),
-                         tag=tag, kind=discr.compute_kind,
-                         dtype=discr.default_scalar_type)
-
-class Vortex:
-    def __init__(self):
-        self.beta = 5
-        self.gamma = 1.4
-        self.center = numpy.array([5, 0])
-        self.velocity = numpy.array([1, 0])
-
-        self.mu = 0
-        self.prandtl = 0.72
-        self.spec_gas_const = 287.1
-
-    def __call__(self, t, x_vec):
-        vortex_loc = self.center + t*self.velocity
-
-        # coordinates relative to vortex center
-        x_rel = x_vec[0] - vortex_loc[0]
-        y_rel = x_vec[1] - vortex_loc[1]
-
-        # Y.C. Zhou, G.W. Wei / Journal of Computational Physics 189 (2003) 159
-        # also JSH/TW Nodal DG Methods, p. 209
-
-        from math import pi
-        r = numpy.sqrt(x_rel**2+y_rel**2)
-        expterm = self.beta*numpy.exp(1-r**2)
-        u = self.velocity[0] - expterm*y_rel/(2*pi)
-        v = self.velocity[1] + expterm*x_rel/(2*pi)
-        rho = (1-(self.gamma-1)/(16*self.gamma*pi**2)*expterm**2)**(1/(self.gamma-1))
-        p = rho**self.gamma
-
-        e = p/(self.gamma-1) + rho/2*(u**2+v**2)
-
-        from grudge.tools import join_fields
-        return join_fields(rho, e, rho*u, rho*v)
-
-    def volume_interpolant(self, t, discr):
-        return discr.convert_volume(
-                        self(t, discr.nodes.T
-                            .astype(discr.default_scalar_type)),
-                        kind=discr.compute_kind)
-
-    def boundary_interpolant(self, t, discr, tag):
-        return discr.convert_boundary(
-                        self(t, discr.get_boundary(tag).nodes.T
-                            .astype(discr.default_scalar_type)),
-                         tag=tag, kind=discr.compute_kind)
-
-
-
-
-
-
-
-class Vortex:
-    def __init__(self):
-        self.beta = 5
-        self.gamma = 1.4
-        self.center = numpy.array([5, 0])
-        self.velocity = numpy.array([1, 0])
-        self.final_time = 0.5
-
-        self.mu = 0
-        self.prandtl = 0.72
-        self.spec_gas_const = 287.1
-
-    def __call__(self, t, x_vec):
-        vortex_loc = self.center + t*self.velocity
-
-        # coordinates relative to vortex center
-        x_rel = x_vec[0] - vortex_loc[0]
-        y_rel = x_vec[1] - vortex_loc[1]
-
-        # Y.C. Zhou, G.W. Wei / Journal of Computational Physics 189 (2003) 159
-        # also JSH/TW Nodal DG Methods, p. 209
-
-        from math import pi
-        r = numpy.sqrt(x_rel**2+y_rel**2)
-        expterm = self.beta*numpy.exp(1-r**2)
-        u = self.velocity[0] - expterm*y_rel/(2*pi)
-        v = self.velocity[1] + expterm*x_rel/(2*pi)
-        rho = (1-(self.gamma-1)/(16*self.gamma*pi**2)*expterm**2)**(1/(self.gamma-1))
-        p = rho**self.gamma
-
-        e = p/(self.gamma-1) + rho/2*(u**2+v**2)
-
-        from grudge.tools import join_fields
-        return join_fields(rho, e, rho*u, rho*v)
-
-    def volume_interpolant(self, t, discr):
-        return discr.convert_volume(
-                        self(t, discr.nodes.T
-                            .astype(discr.default_scalar_type)),
-                        kind=discr.compute_kind)
-
-    def boundary_interpolant(self, t, discr, tag):
-        return discr.convert_boundary(
-                        self(t, discr.get_boundary(tag).nodes.T
-                            .astype(discr.default_scalar_type)),
-                         tag=tag, kind=discr.compute_kind)
-
-
-
-
diff --git a/unported-examples/gas_dynamics/lbm-simple.py b/unported-examples/gas_dynamics/lbm-simple.py
deleted file mode 100644
index b6eb94c6980d613adcd1522254a0ad9fc802464c..0000000000000000000000000000000000000000
--- a/unported-examples/gas_dynamics/lbm-simple.py
+++ /dev/null
@@ -1,162 +0,0 @@
-__copyright__ = "Copyright (C) 2011 Andreas Kloeckner"
-
-__license__ = """
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-"""
-
-
-
-
-from __future__ import division
-from __future__ import absolute_import
-from __future__ import print_function
-import numpy as np
-import numpy.linalg as la
-from six.moves import range
-
-
-
-
-def main(write_output=True, dtype=np.float32):
-    from grudge.backends import guess_run_context
-    rcon = guess_run_context()
-
-    from grudge.mesh.generator import make_rect_mesh
-    if rcon.is_head_rank:
-        h_fac = 1
-        mesh = make_rect_mesh(a=(0,0),b=(1,1), max_area=h_fac**2*1e-4,
-                periodicity=(True,True),
-                subdivisions=(int(70/h_fac), int(70/h_fac)))
-
-    from grudge.models.gas_dynamics.lbm import \
-            D2Q9LBMMethod, LatticeBoltzmannOperator
-
-    op = LatticeBoltzmannOperator(
-            D2Q9LBMMethod(), lbm_delta_t=0.001, nu=1e-4)
-
-    if rcon.is_head_rank:
-        print("%d elements" % len(mesh.elements))
-        mesh_data = rcon.distribute_mesh(mesh)
-    else:
-        mesh_data = rcon.receive_mesh()
-
-    discr = rcon.make_discretization(mesh_data, order=3,
-            default_scalar_type=dtype,
-            debug=["cuda_no_plan"])
-    from grudge.timestep.runge_kutta import LSRK4TimeStepper
-    stepper = LSRK4TimeStepper(dtype=dtype,
-            #vector_primitive_factory=discr.get_vector_primitive_factory()
-            )
-
-    from grudge.visualization import VtkVisualizer
-    if write_output:
-        vis = VtkVisualizer(discr, rcon, "fld")
-
-    from grudge.data import CompiledExpressionData
-    def ic_expr(t, x, fields):
-        from grudge.symbolic import FunctionSymbol
-        from pymbolic.primitives import IfPositive
-        from pytools.obj_array import make_obj_array
-
-        tanh = FunctionSymbol("tanh")
-        sin = FunctionSymbol("sin")
-
-        rho = 1
-        u0 = 0.05
-        w = 0.05
-        delta = 0.05
-
-        from grudge.symbolic.primitives import make_common_subexpression as cse
-        u = cse(make_obj_array([
-            IfPositive(x[1]-1/2,
-                u0*tanh(4*(3/4-x[1])/w),
-                u0*tanh(4*(x[1]-1/4)/w)),
-            u0*delta*sin(2*np.pi*(x[0]+1/4))]),
-            "u")
-
-        return make_obj_array([
-            op.method.f_equilibrium(rho, alpha, u)
-            for alpha in range(len(op.method))
-            ])
-
-
-    # timestep loop -----------------------------------------------------------
-    stream_rhs = op.bind_rhs(discr)
-    collision_update = op.bind(discr, op.collision_update)
-    get_rho = op.bind(discr, op.rho)
-    get_rho_u = op.bind(discr, op.rho_u)
-
-
-    f_bar = CompiledExpressionData(ic_expr).volume_interpolant(0, discr)
-
-    from grudge.discretization import ExponentialFilterResponseFunction
-    from grudge.symbolic.operators import FilterOperator
-    mode_filter = FilterOperator(
-            ExponentialFilterResponseFunction(min_amplification=0.9, order=4))\
-                    .bind(discr)
-
-    final_time = 1000
-    try:
-        lbm_dt = op.lbm_delta_t
-        dg_dt = op.estimate_timestep(discr, stepper=stepper)
-        print(dg_dt)
-
-        dg_steps_per_lbm_step = int(np.ceil(lbm_dt / dg_dt))
-        dg_dt = lbm_dt / dg_steps_per_lbm_step
-
-        lbm_steps = int(final_time // op.lbm_delta_t)
-        for step in range(lbm_steps):
-            t = step*lbm_dt
-
-            if step % 100 == 0 and write_output:
-                visf = vis.make_file("fld-%04d" % step)
-
-                rho = get_rho(f_bar)
-                rho_u = get_rho_u(f_bar)
-                vis.add_data(visf,
-                        [ ("fbar%d" %i,
-                            discr.convert_volume(f_bar_i, "numpy")) for i, f_bar_i in enumerate(f_bar)]+
-                        [
-                            ("rho", discr.convert_volume(rho, "numpy")),
-                            ("rho_u", discr.convert_volume(rho_u, "numpy")),
-                        ],
-                        time=t,
-                        step=step)
-                visf.close()
-
-            print("step=%d, t=%f" % (step, t))
-
-            f_bar = collision_update(f_bar)
-
-            for substep in range(dg_steps_per_lbm_step):
-                f_bar = stepper(f_bar, t + substep*dg_dt, dg_dt, stream_rhs)
-
-            #f_bar = mode_filter(f_bar)
-
-    finally:
-        if write_output:
-            vis.close()
-
-        discr.close()
-
-
-
-
-if __name__ == "__main__":
-    main(True)
diff --git a/unported-examples/gas_dynamics/naca.py b/unported-examples/gas_dynamics/naca.py
deleted file mode 100644
index 0c200c04bbda31b76ac7548de55f614546fd5054..0000000000000000000000000000000000000000
--- a/unported-examples/gas_dynamics/naca.py
+++ /dev/null
@@ -1,275 +0,0 @@
-__copyright__ = "Copyright (C) 2008 Andreas Kloeckner"
-
-__license__ = """
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-"""
-
-
-from __future__ import division
-from __future__ import absolute_import
-from __future__ import print_function
-import numpy
-import numpy.linalg as la
-from six.moves import range
-
-
-
-
-def make_nacamesh():
-    def round_trip_connect(seq):
-        result = []
-        for i in range(len(seq)):
-            result.append((i, (i+1)%len(seq)))
-        return result
-
-    pt_back = numpy.array([1,0])
-
-    #def max_area(pt):
-        #max_area_front = 1e-2*la.norm(pt)**2 + 1e-5
-        #max_area_back = 1e-2*la.norm(pt-pt_back)**2 + 1e-4
-        #return min(max_area_front, max_area_back)
-
-    def max_area(pt):
-        x = pt[0]
-
-        if x < 0:
-            return 1e-2*la.norm(pt)**2 + 1e-5
-        elif x > 1:
-            return 1e-2*la.norm(pt-pt_back)**2 + 1e-5
-        else:
-            return 1e-2*pt[1]**2 + 1e-5
-
-    def needs_refinement(vertices, area):
-        barycenter =  sum(numpy.array(v) for v in vertices)/3
-        return bool(area > max_area(barycenter))
-
-    from meshpy.naca import get_naca_points
-    points = get_naca_points(naca_digits="2412", number_of_points=80)
-
-    from meshpy.geometry import GeometryBuilder, Marker
-    from meshpy.triangle import write_gnuplot_mesh
-
-    profile_marker = Marker.FIRST_USER_MARKER
-    builder = GeometryBuilder()
-    builder.add_geometry(points=points,
-            facets=round_trip_connect(points),
-            facet_markers=profile_marker)
-    builder.wrap_in_box(4, (10, 8))
-
-    from meshpy.triangle import MeshInfo, build
-    mi = MeshInfo()
-    builder.set(mi)
-    mi.set_holes([builder.center()])
-
-    mesh = build(mi, refinement_func=needs_refinement,
-            #allow_boundary_steiner=False,
-            generate_faces=True)
-
-    write_gnuplot_mesh("mesh.dat", mesh)
-
-    print("%d elements" % len(mesh.elements))
-
-    fvi2fm = mesh.face_vertex_indices_to_face_marker
-
-    face_marker_to_tag = {
-            profile_marker: "noslip",
-            Marker.MINUS_X: "inflow",
-            Marker.PLUS_X: "outflow",
-            Marker.MINUS_Y: "inflow",
-            Marker.PLUS_Y: "inflow"
-            #Marker.MINUS_Y: "minus_y",
-            #Marker.PLUS_Y: "plus_y"
-            }
-
-    def bdry_tagger(fvi, el, fn, all_v):
-        face_marker = fvi2fm[fvi]
-        return [face_marker_to_tag[face_marker]]
-
-    from grudge.mesh import make_conformal_mesh_ext
-
-    vertices = numpy.asarray(mesh.points, order="C")
-    from grudge.mesh.element import Triangle
-    return make_conformal_mesh_ext(
-            vertices,
-            [Triangle(i, el_idx, vertices)
-                for i, el_idx in enumerate(mesh.elements)],
-            bdry_tagger,
-            #periodicity=[None, ("minus_y", "plus_y")]
-            )
-
-
-
-
-def main():
-    from grudge.backends import guess_run_context
-    rcon = guess_run_context()
-
-    if rcon.is_head_rank:
-        mesh = make_nacamesh()
-        mesh_data = rcon.distribute_mesh(mesh)
-    else:
-        mesh_data = rcon.receive_mesh()
-
-    from pytools import add_python_path_relative_to_script
-    add_python_path_relative_to_script("..")
-
-    for order in [4]:
-        from gas_dynamics_initials import UniformMachFlow
-        uniform_flow = UniformMachFlow()
-
-        from grudge.models.gas_dynamics import GasDynamicsOperator, GammaLawEOS
-        op = GasDynamicsOperator(dimensions=2,
-                equation_of_state=GammaLawEOS(uniform_flow.gamma),
-                prandtl=uniform_flow.prandtl,
-                spec_gas_const=uniform_flow.spec_gas_const, mu=uniform_flow.mu,
-                bc_inflow=uniform_flow, bc_outflow=uniform_flow, bc_noslip=uniform_flow,
-                inflow_tag="inflow", outflow_tag="outflow", noslip_tag="noslip")
-
-        discr = rcon.make_discretization(mesh_data, order=order,
-                        debug=[
-                            "cuda_no_plan",
-                            #"cuda_dump_kernels",
-                            #"dump_optemplate_stages",
-                            #"dump_dataflow_graph",
-                            #"print_op_code"
-                            ],
-                        default_scalar_type=numpy.float32,
-                        tune_for=op.sym_operator())
-
-        from grudge.visualization import SiloVisualizer, VtkVisualizer
-        #vis = VtkVisualizer(discr, rcon, "shearflow-%d" % order)
-        vis = SiloVisualizer(discr, rcon)
-
-        fields = uniform_flow.volume_interpolant(0, discr)
-
-        navierstokes_ex = op.bind(discr)
-
-        max_eigval = [0]
-        def rhs(t, q):
-            ode_rhs, speed = navierstokes_ex(t, q)
-            max_eigval[0] = speed
-            return ode_rhs
-        rhs(0, fields)
-
-        if rcon.is_head_rank:
-            print("---------------------------------------------")
-            print("order %d" % order)
-            print("---------------------------------------------")
-            print("#elements=", len(mesh.elements))
-
-        from grudge.timestep.runge_kutta import \
-                ODE23TimeStepper, LSRK4TimeStepper
-        stepper = ODE23TimeStepper(dtype=discr.default_scalar_type,
-                rtol=1e-6,
-                vector_primitive_factory=discr.get_vector_primitive_factory())
-        #stepper = LSRK4TimeStepper(dtype=discr.default_scalar_type)
-
-        # diagnostics setup ---------------------------------------------------
-        from logpyle import LogManager, add_general_quantities, \
-                add_simulation_quantities, add_run_info
-
-        logmgr = LogManager("cns-naca-%d.dat" % order, "w", rcon.communicator)
-
-        add_run_info(logmgr)
-        add_general_quantities(logmgr)
-        add_simulation_quantities(logmgr)
-        discr.add_instrumentation(logmgr)
-        stepper.add_instrumentation(logmgr)
-
-        from logpyle import LogQuantity
-        class ChangeSinceLastStep(LogQuantity):
-            """Records the change of a variable between a time step and the previous
-               one"""
-
-            def __init__(self, name="change"):
-                LogQuantity.__init__(self, name, "1", "Change since last time step")
-
-                self.old_fields = 0
-
-            def __call__(self):
-                result = discr.norm(fields - self.old_fields)
-                self.old_fields = fields
-                return result
-
-        #logmgr.add_quantity(ChangeSinceLastStep())
-
-        # filter setup-------------------------------------------------------------
-        from grudge.discretization import Filter, ExponentialFilterResponseFunction
-        mode_filter = Filter(discr,
-                ExponentialFilterResponseFunction(min_amplification=0.9,order=4))
-        # timestep loop -------------------------------------------------------
-
-        logmgr.add_watches(["step.max", "t_sim.max", "t_step.max"])
-
-        try:
-            from grudge.timestep import times_and_steps
-            step_it = times_and_steps(
-                    final_time=200,
-                    #max_steps=500,
-                    logmgr=logmgr,
-                    max_dt_getter=lambda t: next_dt,
-                    taken_dt_getter=lambda: taken_dt)
-
-            model_stepper = LSRK4TimeStepper()
-            next_dt = op.estimate_timestep(discr,
-                    stepper=model_stepper, t=0,
-                    max_eigenvalue=max_eigval[0])
-
-            for step, t, dt in step_it:
-                if step % 10 == 0:
-                    visf = vis.make_file("naca-%d-%06d" % (order, step))
-
-                    from pyvisfile.silo import DB_VARTYPE_VECTOR
-                    vis.add_data(visf,
-                            [
-                                ("rho", discr.convert_volume(op.rho(fields), kind="numpy")),
-                                ("e", discr.convert_volume(op.e(fields), kind="numpy")),
-                                ("rho_u", discr.convert_volume(op.rho_u(fields), kind="numpy")),
-                                ("u", discr.convert_volume(op.u(fields), kind="numpy")),
-
-                                #("true_rho", op.rho(true_fields)),
-                                #("true_e", op.e(true_fields)),
-                                #("true_rho_u", op.rho_u(true_fields)),
-                                #("true_u", op.u(true_fields)),
-
-                                #("rhs_rho", discr.convert_volume(op.rho(rhs_fields), kind="numpy")),
-                                #("rhs_e", discr.convert_volume(op.e(rhs_fields), kind="numpy")),
-                                #("rhs_rho_u", discr.convert_volume(op.rho_u(rhs_fields), kind="numpy")),
-                                ],
-                            expressions=[
-                                #("diff_rho", "rho-true_rho"),
-                                #("diff_e", "e-true_e"),
-                                #("diff_rho_u", "rho_u-true_rho_u", DB_VARTYPE_VECTOR),
-
-                                ("p", "(0.4)*(e- 0.5*(rho_u*u))"),
-                                ],
-                            time=t, step=step
-                            )
-                    visf.close()
-
-                fields, t, taken_dt, next_dt = stepper(fields, t, dt, rhs)
-                fields = mode_filter(fields)
-
-        finally:
-            vis.close()
-            logmgr.save()
-            discr.close()
-
-if __name__ == "__main__":
-    main()
diff --git a/unported-examples/gas_dynamics/navierstokes/shearflow.py b/unported-examples/gas_dynamics/navierstokes/shearflow.py
deleted file mode 100644
index 6c762e1239045cefc8fc6b711f6c3bef423d4eb6..0000000000000000000000000000000000000000
--- a/unported-examples/gas_dynamics/navierstokes/shearflow.py
+++ /dev/null
@@ -1,205 +0,0 @@
-__copyright__ = "Copyright (C) 2008 Andreas Kloeckner"
-
-__license__ = """
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-"""
-
-
-
-from __future__ import division
-from __future__ import absolute_import
-from __future__ import print_function
-import numpy
-import numpy.linalg as la
-
-
-
-
-class SteadyShearFlow:
-    def __init__(self):
-        self.gamma = 1.5
-        self.mu = 0.01
-        self.prandtl = 0.72
-        self.spec_gas_const = 287.1
-
-    def __call__(self, t, x_vec):
-        # JSH/TW Nodal DG Methods, p.326
-
-        rho = numpy.ones_like(x_vec[0])
-        rho_u = x_vec[1] * x_vec[1]
-        rho_v = numpy.zeros_like(x_vec[0])
-        e = (2 * self.mu * x_vec[0] + 10) / (self.gamma - 1) + x_vec[1]**4 / 2
-
-        from grudge.tools import join_fields
-        return join_fields(rho, e, rho_u, rho_v)
-
-    def properties(self):
-        return(self.gamma, self.mu, self.prandtl, self.spec_gas_const)
-
-    def volume_interpolant(self, t, discr):
-        return discr.convert_volume(
-                        self(t, discr.nodes.T
-                            .astype(discr.default_scalar_type)),
-                        kind=discr.compute_kind)
-
-    def boundary_interpolant(self, t, discr, tag):
-        result = discr.convert_boundary(
-                        self(t, discr.get_boundary(tag).nodes.T
-                            .astype(discr.default_scalar_type)),
-                        tag=tag, kind=discr.compute_kind)
-        return result
-
-
-
-
-def main():
-    from grudge.backends import guess_run_context
-    rcon = guess_run_context(
-    #["cuda"]
-    )
-
-    from grudge.tools import EOCRecorder, to_obj_array
-    eoc_rec = EOCRecorder()
-
-    def boundary_tagger(vertices, el, face_nr, all_v):
-        return ["inflow"]
-
-    if rcon.is_head_rank:
-        from grudge.mesh import make_rect_mesh, \
-                               make_centered_regular_rect_mesh
-        #mesh = make_rect_mesh((0,0), (10,1), max_area=0.01)
-        refine = 1
-        mesh = make_centered_regular_rect_mesh((0,0), (10,1), n=(20,4),
-                            #periodicity=(True, False),
-                            post_refine_factor=refine,
-                            boundary_tagger=boundary_tagger)
-        mesh_data = rcon.distribute_mesh(mesh)
-    else:
-        mesh_data = rcon.receive_mesh()
-
-    for order in [3]:
-        discr = rcon.make_discretization(mesh_data, order=order,
-                        default_scalar_type=numpy.float64)
-
-        from grudge.visualization import SiloVisualizer, VtkVisualizer
-        #vis = VtkVisualizer(discr, rcon, "shearflow-%d" % order)
-        vis = SiloVisualizer(discr, rcon)
-
-        shearflow = SteadyShearFlow()
-        fields = shearflow.volume_interpolant(0, discr)
-        gamma, mu, prandtl, spec_gas_const = shearflow.properties()
-
-        from grudge.models.gas_dynamics import GasDynamicsOperator
-        op = GasDynamicsOperator(dimensions=2, gamma=gamma, mu=mu,
-                prandtl=prandtl, spec_gas_const=spec_gas_const,
-                bc_inflow=shearflow, bc_outflow=shearflow, bc_noslip=shearflow,
-                inflow_tag="inflow", outflow_tag="outflow", noslip_tag="noslip")
-
-        navierstokes_ex = op.bind(discr)
-
-        max_eigval = [0]
-        def rhs(t, q):
-            ode_rhs, speed = navierstokes_ex(t, q)
-            max_eigval[0] = speed
-            return ode_rhs
-
-        # needed to get first estimate of maximum eigenvalue
-        rhs(0, fields)
-
-        if rcon.is_head_rank:
-            print("---------------------------------------------")
-            print("order %d" % order)
-            print("---------------------------------------------")
-            print("#elements=", len(mesh.elements))
-
-        from grudge.timestep import RK4TimeStepper
-        stepper = RK4TimeStepper()
-
-        # diagnostics setup ---------------------------------------------------
-        from logpyle import LogManager, add_general_quantities, \
-                add_simulation_quantities, add_run_info
-
-        logmgr = LogManager("navierstokes-cpu-%d-%d.dat" % (order, refine),
-                            "w", rcon.communicator)
-        add_run_info(logmgr)
-        add_general_quantities(logmgr)
-        add_simulation_quantities(logmgr)
-        discr.add_instrumentation(logmgr)
-        stepper.add_instrumentation(logmgr)
-
-        logmgr.add_watches(["step.max", "t_sim.max", "t_step.max"])
-
-        # timestep loop -------------------------------------------------------
-        try:
-            from grudge.timestep import times_and_steps
-            step_it = times_and_steps(
-                    final_time=0.3,
-                    #max_steps=500,
-                    logmgr=logmgr,
-                    max_dt_getter=lambda t: op.estimate_timestep(discr,
-                        stepper=stepper, t=t, max_eigenvalue=max_eigval[0]))
-
-            for step, t, dt in step_it:
-                if step % 10 == 0:
-                #if False:
-                    visf = vis.make_file("shearflow-%d-%04d" % (order, step))
-
-                    #true_fields = shearflow.volume_interpolant(t, discr)
-
-                    from pyvisfile.silo import DB_VARTYPE_VECTOR
-                    vis.add_data(visf,
-                            [
-                                ("rho", discr.convert_volume(op.rho(fields), kind="numpy")),
-                                ("e", discr.convert_volume(op.e(fields), kind="numpy")),
-                                ("rho_u", discr.convert_volume(op.rho_u(fields), kind="numpy")),
-                                ("u", discr.convert_volume(op.u(fields), kind="numpy")),
-
-                                #("true_rho", discr.convert_volume(op.rho(true_fields), kind="numpy")),
-                                #("true_e", discr.convert_volume(op.e(true_fields), kind="numpy")),
-                                #("true_rho_u", discr.convert_volume(op.rho_u(true_fields), kind="numpy")),
-                                #("true_u", discr.convert_volume(op.u(true_fields), kind="numpy")),
-                                ],
-                            expressions=[
-                                #("diff_rho", "rho-true_rho"),
-                                #("diff_e", "e-true_e"),
-                                #("diff_rho_u", "rho_u-true_rho_u", DB_VARTYPE_VECTOR),
-
-                                ("p", "0.4*(e- 0.5*(rho_u*u))"),
-                                ],
-                            time=t, step=step
-                            )
-                    visf.close()
-
-                fields = stepper(fields, t, dt, rhs)
-
-            true_fields = shearflow.volume_interpolant(t, discr)
-            l2_error = discr.norm(op.u(fields)-op.u(true_fields))
-            eoc_rec.add_data_point(order, l2_error)
-            print()
-            print(eoc_rec.pretty_print("P.Deg.", "L2 Error"))
-
-            logmgr.set_constant("l2_error", l2_error)
-
-        finally:
-            vis.close()
-            logmgr.save()
-            discr.close()
-
-if __name__ == "__main__":
-    main()
diff --git a/unported-examples/gas_dynamics/square.py b/unported-examples/gas_dynamics/square.py
deleted file mode 100644
index 632cfb7293ba83d2c54cef97df3c1d449a1baf16..0000000000000000000000000000000000000000
--- a/unported-examples/gas_dynamics/square.py
+++ /dev/null
@@ -1,288 +0,0 @@
-__copyright__ = "Copyright (C) 2008 Andreas Kloeckner"
-
-__license__ = """
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-"""
-
-
-from __future__ import division
-from __future__ import absolute_import
-from __future__ import print_function
-import numpy
-import numpy.linalg as la
-from six.moves import range
-
-
-
-
-def make_squaremesh():
-    def round_trip_connect(seq):
-        result = []
-        for i in range(len(seq)):
-            result.append((i, (i+1)%len(seq)))
-        return result
-
-    def needs_refinement(vertices, area):
-        x =  sum(numpy.array(v) for v in vertices)/3
-
-        max_area_volume = 0.7e-2 + 0.03*(0.05*x[1]**2 + 0.3*min(x[0]+1,0)**2)
-
-        max_area_corners = 1e-3 + 0.001*max(
-                la.norm(x-corner)**4 for corner in obstacle_corners)
-
-        return bool(area > 2.5*min(max_area_volume, max_area_corners))
-
-    from meshpy.geometry import make_box
-    points, facets, _, _ = make_box((-0.5,-0.5), (0.5,0.5))
-    obstacle_corners = points[:]
-
-    from meshpy.geometry import GeometryBuilder, Marker
-
-    profile_marker = Marker.FIRST_USER_MARKER
-    builder = GeometryBuilder()
-    builder.add_geometry(points=points, facets=facets,
-            facet_markers=profile_marker)
-
-    points, facets, _, facet_markers = make_box((-16, -22), (25, 22))
-    builder.add_geometry(points=points, facets=facets,
-            facet_markers=facet_markers)
-
-    from meshpy.triangle import MeshInfo, build
-    mi = MeshInfo()
-    builder.set(mi)
-    mi.set_holes([(0,0)])
-
-    mesh = build(mi, refinement_func=needs_refinement,
-            allow_boundary_steiner=True,
-            generate_faces=True)
-
-    print("%d elements" % len(mesh.elements))
-
-    from meshpy.triangle import write_gnuplot_mesh
-    write_gnuplot_mesh("mesh.dat", mesh)
-
-    fvi2fm = mesh.face_vertex_indices_to_face_marker
-
-    face_marker_to_tag = {
-            profile_marker: "noslip",
-            Marker.MINUS_X: "inflow",
-            Marker.PLUS_X: "outflow",
-            Marker.MINUS_Y: "inflow",
-            Marker.PLUS_Y: "inflow"
-            }
-
-    def bdry_tagger(fvi, el, fn, all_v):
-        face_marker = fvi2fm[fvi]
-        return [face_marker_to_tag[face_marker]]
-
-    from grudge.mesh import make_conformal_mesh_ext
-    vertices = numpy.asarray(mesh.points, dtype=float, order="C")
-    from grudge.mesh.element import Triangle
-    return make_conformal_mesh_ext(
-            vertices,
-            [Triangle(i, el_idx, vertices)
-                for i, el_idx in enumerate(mesh.elements)],
-            bdry_tagger)
-
-
-
-
-def main():
-    import logging
-    logging.basicConfig(level=logging.INFO)
-
-    from grudge.backends import guess_run_context
-    rcon = guess_run_context()
-
-    if rcon.is_head_rank:
-        if True:
-            mesh = make_squaremesh()
-        else:
-            from grudge.mesh import make_rect_mesh
-            mesh = make_rect_mesh(
-                   boundary_tagger=lambda fvi, el, fn, all_v: ["inflow"],
-                   max_area=0.1)
-
-        mesh_data = rcon.distribute_mesh(mesh)
-    else:
-        mesh_data = rcon.receive_mesh()
-
-    from pytools import add_python_path_relative_to_script
-    add_python_path_relative_to_script(".")
-
-    for order in [3]:
-        from gas_dynamics_initials import UniformMachFlow
-        square = UniformMachFlow(gaussian_pulse_at=numpy.array([-2, 2]),
-                pulse_magnitude=0.003)
-
-        from grudge.models.gas_dynamics import (
-                GasDynamicsOperator,
-                GammaLawEOS)
-
-        op = GasDynamicsOperator(dimensions=2,
-                equation_of_state=GammaLawEOS(square.gamma), mu=square.mu,
-                prandtl=square.prandtl, spec_gas_const=square.spec_gas_const,
-                bc_inflow=square, bc_outflow=square, bc_noslip=square,
-                inflow_tag="inflow", outflow_tag="outflow", noslip_tag="noslip")
-
-        discr = rcon.make_discretization(mesh_data, order=order,
-                        debug=["cuda_no_plan",
-                            "cuda_dump_kernels",
-                            #"dump_dataflow_graph",
-                            #"dump_optemplate_stages",
-                            #"dump_dataflow_graph",
-                            #"dump_op_code"
-                            #"cuda_no_plan_el_local"
-                            ],
-                        default_scalar_type=numpy.float64,
-                        tune_for=op.sym_operator(),
-                        quad_min_degrees={
-                            "gasdyn_vol": 3*order,
-                            "gasdyn_face": 3*order,
-                            }
-                        )
-
-        from grudge.visualization import SiloVisualizer, VtkVisualizer
-        #vis = VtkVisualizer(discr, rcon, "shearflow-%d" % order)
-        vis = SiloVisualizer(discr, rcon)
-
-        from grudge.timestep.runge_kutta import (
-                LSRK4TimeStepper, ODE23TimeStepper, ODE45TimeStepper)
-        from grudge.timestep.dumka3 import Dumka3TimeStepper
-        #stepper = LSRK4TimeStepper(dtype=discr.default_scalar_type,
-                #vector_primitive_factory=discr.get_vector_primitive_factory())
-
-        stepper = ODE23TimeStepper(dtype=discr.default_scalar_type,
-                rtol=1e-6,
-                vector_primitive_factory=discr.get_vector_primitive_factory())
-        # Dumka works kind of poorly
-        #stepper = Dumka3TimeStepper(dtype=discr.default_scalar_type,
-                #rtol=1e-7, pol_index=2,
-                #vector_primitive_factory=discr.get_vector_primitive_factory())
-
-        #from grudge.timestep.dumka3 import Dumka3TimeStepper
-        #stepper = Dumka3TimeStepper(3, rtol=1e-7)
-
-        # diagnostics setup ---------------------------------------------------
-        from logpyle import LogManager, add_general_quantities, \
-                add_simulation_quantities, add_run_info
-
-        logmgr = LogManager("cns-square-sp-%d.dat" % order, "w", rcon.communicator)
-
-        add_run_info(logmgr)
-        add_general_quantities(logmgr)
-        discr.add_instrumentation(logmgr)
-        stepper.add_instrumentation(logmgr)
-
-        from logpyle import LogQuantity
-        class ChangeSinceLastStep(LogQuantity):
-            """Records the change of a variable between a time step and the previous
-               one"""
-
-            def __init__(self, name="change"):
-                LogQuantity.__init__(self, name, "1", "Change since last time step")
-
-                self.old_fields = 0
-
-            def __call__(self):
-                result = discr.norm(fields - self.old_fields)
-                self.old_fields = fields
-                return result
-
-        #logmgr.add_quantity(ChangeSinceLastStep())
-
-        add_simulation_quantities(logmgr)
-        logmgr.add_watches(["step.max", "t_sim.max", "t_step.max"])
-
-        # filter setup ------------------------------------------------------------
-        from grudge.discretization import Filter, ExponentialFilterResponseFunction
-        mode_filter = Filter(discr,
-                ExponentialFilterResponseFunction(min_amplification=0.95, order=6))
-
-        # timestep loop -------------------------------------------------------
-        fields = square.volume_interpolant(0, discr)
-
-        navierstokes_ex = op.bind(discr)
-
-        max_eigval = [0]
-        def rhs(t, q):
-            ode_rhs, speed = navierstokes_ex(t, q)
-            max_eigval[0] = speed
-            return ode_rhs
-        rhs(0, fields)
-
-        if rcon.is_head_rank:
-            print("---------------------------------------------")
-            print("order %d" % order)
-            print("---------------------------------------------")
-            print("#elements=", len(mesh.elements))
-
-        try:
-            from grudge.timestep import times_and_steps
-            step_it = times_and_steps(
-                    final_time=1000,
-                    #max_steps=500,
-                    logmgr=logmgr,
-                    max_dt_getter=lambda t: next_dt,
-                    taken_dt_getter=lambda: taken_dt)
-
-            model_stepper = LSRK4TimeStepper()
-            next_dt = op.estimate_timestep(discr,
-                    stepper=model_stepper, t=0,
-                    max_eigenvalue=max_eigval[0])
-
-            for step, t, dt in step_it:
-                #if (step % 10000 == 0): #and step < 950000) or (step % 500 == 0 and step > 950000):
-                #if False:
-                if step % 5 == 0:
-                    visf = vis.make_file("square-%d-%06d" % (order, step))
-
-                    #from pyvisfile.silo import DB_VARTYPE_VECTOR
-                    vis.add_data(visf,
-                            [
-                                ("rho", discr.convert_volume(op.rho(fields), kind="numpy")),
-                                ("e", discr.convert_volume(op.e(fields), kind="numpy")),
-                                ("rho_u", discr.convert_volume(op.rho_u(fields), kind="numpy")),
-                                ("u", discr.convert_volume(op.u(fields), kind="numpy")),
-                            ],
-                            expressions=[
-                                ("p", "(0.4)*(e- 0.5*(rho_u*u))"),
-                                ],
-                            time=t, step=step
-                            )
-                    visf.close()
-
-                if stepper.adaptive:
-                    fields, t, taken_dt, next_dt = stepper(fields, t, dt, rhs)
-                else:
-                    taken_dt = dt
-                    fields = stepper(fields, t, dt, rhs)
-                    dt = op.estimate_timestep(discr,
-                            stepper=model_stepper, t=0,
-                            max_eigenvalue=max_eigval[0])
-
-                #fields = mode_filter(fields)
-
-        finally:
-            vis.close()
-            logmgr.save()
-            discr.close()
-
-if __name__ == "__main__":
-    main()
diff --git a/unported-examples/gas_dynamics/wing.py b/unported-examples/gas_dynamics/wing.py
deleted file mode 100644
index 2017d0f4d83a8c7e28cce3dca121968cf7965c3e..0000000000000000000000000000000000000000
--- a/unported-examples/gas_dynamics/wing.py
+++ /dev/null
@@ -1,242 +0,0 @@
-__copyright__ = "Copyright (C) 2008 Andreas Kloeckner"
-
-__license__ = """
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-"""
-
-
-
-
-
-from __future__ import division
-from __future__ import absolute_import
-from __future__ import print_function
-import numpy
-import numpy.linalg as la
-from six.moves import zip
-
-
-
-
-def make_wingmesh():
-    import numpy
-    from math import pi, cos, sin
-    from meshpy.tet import MeshInfo, build
-    from meshpy.geometry import GeometryBuilder, Marker, \
-            generate_extrusion, make_box
-
-    geob = GeometryBuilder()
-
-    profile_marker = Marker.FIRST_USER_MARKER
-
-    wing_length = 2
-    wing_subdiv = 5
-
-    rz_points = [
-            (0, -wing_length*1.05),
-            (0.7, -wing_length*1.05),
-            ] + [
-                (r, x) for x, r in zip(
-                    numpy.linspace(-wing_length, 0, wing_subdiv, endpoint=False),
-                    numpy.linspace(0.8, 1, wing_subdiv, endpoint=False))
-            ] + [(1,0)] + [
-                (r, x) for x, r in zip(
-                    numpy.linspace(wing_length, 0, wing_subdiv, endpoint=False),
-                    numpy.linspace(0.8, 1, wing_subdiv, endpoint=False))
-            ][::-1] + [
-            (0.7, wing_length*1.05),
-            (0, wing_length*1.05)
-            ]
-
-    from meshpy.naca import get_naca_points
-    geob.add_geometry(*generate_extrusion(
-        rz_points=rz_points,
-        base_shape=get_naca_points("0012", number_of_points=20),
-        ring_markers=(wing_subdiv*2+4)*[profile_marker]))
-
-    def deform_wing(p):
-        x, y, z = p
-        return numpy.array([
-            x + 0.8*abs(z/wing_length)** 1.2,
-            y + 0.1*abs(z/wing_length)**2,
-            z])
-
-    geob.apply_transform(deform_wing)
-
-    points, facets, facet_markers = make_box(
-            numpy.array([-1.5,-1,-wing_length-1], dtype=numpy.float64),
-            numpy.array([3,1,wing_length+1], dtype=numpy.float64))
-
-    geob.add_geometry(points, facets, facet_markers=facet_markers)
-
-    mesh_info = MeshInfo()
-    geob.set(mesh_info)
-    mesh_info.set_holes([(0.5,0,0)])
-
-    mesh = build(mesh_info)
-    print("%d elements" % len(mesh.elements))
-
-    fvi2fm = mesh.face_vertex_indices_to_face_marker
-
-    face_marker_to_tag = {
-            profile_marker: "noslip",
-            Marker.MINUS_X: "inflow",
-            Marker.PLUS_X: "outflow",
-            Marker.MINUS_Y: "inflow",
-            Marker.PLUS_Y: "inflow",
-            Marker.PLUS_Z: "inflow",
-            Marker.MINUS_Z: "inflow"
-            }
-
-    def bdry_tagger(fvi, el, fn, all_v):
-        face_marker = fvi2fm[fvi]
-        return [face_marker_to_tag[face_marker]]
-
-    from grudge.mesh import make_conformal_mesh
-    return make_conformal_mesh(mesh.points, mesh.elements, bdry_tagger)
-
-
-
-
-def main():
-    from grudge.backends import guess_run_context
-    rcon = guess_run_context( ["cuda", "mpi"])
-
-    if rcon.is_head_rank:
-        mesh = make_wingmesh()
-        #from grudge.mesh import make_rect_mesh
-        #mesh = make_rect_mesh(
-        #       boundary_tagger=lambda fvi, el, fn, all_v: ["inflow"])
-        mesh_data = rcon.distribute_mesh(mesh)
-    else:
-        mesh_data = rcon.receive_mesh()
-
-    for order in [3]:
-        from pytools import add_python_path_relative_to_script
-        add_python_path_relative_to_script("..")
-
-        from gas_dynamics_initials import UniformMachFlow
-        wing = UniformMachFlow(angle_of_attack=0)
-
-        from grudge.models.gas_dynamics import GasDynamicsOperator
-        op = GasDynamicsOperator(dimensions=3,
-                gamma=wing.gamma, mu=wing.mu,
-                prandtl=wing.prandtl, spec_gas_const=wing.spec_gas_const,
-                bc_inflow=wing, bc_outflow=wing, bc_noslip=wing,
-                inflow_tag="inflow", outflow_tag="outflow", noslip_tag="noslip")
-
-        discr = rcon.make_discretization(mesh_data, order=order,
-                        debug=["cuda_no_plan",
-                            #"cuda_dump_kernels",
-                            #"dump_dataflow_graph",
-                            #"dump_optemplate_stages",
-                            #"dump_dataflow_graph",
-                            #"print_op_code"
-                            "cuda_no_metis",
-                            ],
-                        default_scalar_type=numpy.float64,
-                        tune_for=op.sym_operator())
-
-        from grudge.visualization import SiloVisualizer, VtkVisualizer
-        #vis = VtkVisualizer(discr, rcon, "shearflow-%d" % order)
-        vis = SiloVisualizer(discr, rcon)
-
-        fields = wing.volume_interpolant(0, discr)
-
-        navierstokes_ex = op.bind(discr)
-
-        max_eigval = [0]
-        def rhs(t, q):
-            ode_rhs, speed = navierstokes_ex(t, q)
-            max_eigval[0] = speed
-            return ode_rhs
-        rhs(0, fields)
-
-        if rcon.is_head_rank:
-            print("---------------------------------------------")
-            print("order %d" % order)
-            print("---------------------------------------------")
-            print("#elements=", len(mesh.elements))
-
-        from grudge.timestep import RK4TimeStepper
-        stepper = RK4TimeStepper()
-
-        # diagnostics setup ---------------------------------------------------
-        from logpyle import LogManager, add_general_quantities, \
-                add_simulation_quantities, add_run_info
-
-        logmgr = LogManager("navierstokes-%d.dat" % order, "w", rcon.communicator)
-        add_run_info(logmgr)
-        add_general_quantities(logmgr)
-        add_simulation_quantities(logmgr)
-        discr.add_instrumentation(logmgr)
-        stepper.add_instrumentation(logmgr)
-
-        logmgr.add_watches(["step.max", "t_sim.max", "t_step.max"])
-
-        # timestep loop -------------------------------------------------------
-        try:
-            from grudge.timestep import times_and_steps
-            step_it = times_and_steps(
-                    final_time=200,
-                    #max_steps=500,
-                    logmgr=logmgr,
-                    max_dt_getter=lambda t: 0.6 * op.estimate_timestep(discr,
-                        stepper=stepper, t=t, max_eigenvalue=max_eigval[0]))
-
-            for step, t, dt in step_it:
-                if step % 200 == 0:
-                #if False:
-                    visf = vis.make_file("wing-%d-%06d" % (order, step))
-
-                    #rhs_fields = rhs(t, fields)
-
-                    from pyvisfile.silo import DB_VARTYPE_VECTOR
-                    from grudge.discretization import ones_on_boundary
-                    vis.add_data(visf,
-                            [
-                                ("rho", discr.convert_volume(op.rho(fields), kind="numpy")),
-                                ("e", discr.convert_volume(op.e(fields), kind="numpy")),
-                                ("rho_u", discr.convert_volume(op.rho_u(fields), kind="numpy")),
-                                ("u", discr.convert_volume(op.u(fields), kind="numpy")),
-
-                                #("rhs_rho", discr.convert_volume(op.rho(rhs_fields), kind="numpy")),
-                                #("rhs_e", discr.convert_volume(op.e(rhs_fields), kind="numpy")),
-                                #("rhs_rho_u", discr.convert_volume(op.rho_u(rhs_fields), kind="numpy")),
-                                ],
-                            expressions=[
-                                ("p", "(0.4)*(e- 0.5*(rho_u*u))"),
-                                ],
-                            time=t, step=step
-                            )
-                    visf.close()
-
-                fields = stepper(fields, t, dt, rhs)
-                t += dt
-
-        finally:
-            vis.close()
-            logmgr.save()
-            discr.close()
-
-
-
-
-if __name__ == "__main__":
-    main()
diff --git a/unported-examples/heat/heat.py b/unported-examples/heat/heat.py
deleted file mode 100644
index 0aa8fa92f308e171b1030c2210e6765058a47b6c..0000000000000000000000000000000000000000
--- a/unported-examples/heat/heat.py
+++ /dev/null
@@ -1,183 +0,0 @@
-from __future__ import absolute_import
-from __future__ import print_function
-
-__copyright__ = "Copyright (C) 2007 Andreas Kloeckner"
-
-__license__ = """
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-"""
-
-
-
-
-import numpy
-import numpy.linalg as la
-
-
-
-
-def main(write_output=True) :
-    from math import sin, cos, pi, exp, sqrt
-    from grudge.data import TimeConstantGivenFunction, \
-            ConstantGivenFunction
-
-    from grudge.backends import guess_run_context
-    rcon = guess_run_context()
-
-    dim = 2
-
-    def boundary_tagger(fvi, el, fn, all_v):
-        if el.face_normals[fn][0] > 0:
-            return ["dirichlet"]
-        else:
-            return ["neumann"]
-
-    if dim == 2:
-        if rcon.is_head_rank:
-            from grudge.mesh.generator import make_disk_mesh
-            mesh = make_disk_mesh(r=0.5, boundary_tagger=boundary_tagger)
-    elif dim == 3:
-        if rcon.is_head_rank:
-            from grudge.mesh.generator import make_ball_mesh
-            mesh = make_ball_mesh(max_volume=0.001)
-    else:
-        raise RuntimeError("bad number of dimensions")
-
-    if rcon.is_head_rank:
-        print("%d elements" % len(mesh.elements))
-        mesh_data = rcon.distribute_mesh(mesh)
-    else:
-        mesh_data = rcon.receive_mesh()
-
-    discr = rcon.make_discretization(mesh_data, order=3,
-            debug=["cuda_no_plan"],
-            default_scalar_type=numpy.float64)
-
-    if write_output:
-        from grudge.visualization import  VtkVisualizer
-        vis = VtkVisualizer(discr, rcon, "fld")
-
-    def u0(x, el):
-        if la.norm(x) < 0.2:
-            return 1
-        else:
-            return 0
-
-    def coeff(x, el):
-        if x[0] < 0:
-            return 0.25
-        else:
-            return 1
-
-    def dirichlet_bc(t, x):
-        return 0
-
-    def neumann_bc(t, x):
-        return 2
-
-    from grudge.models.diffusion import DiffusionOperator
-    op = DiffusionOperator(discr.dimensions,
-            #coeff=coeff,
-            dirichlet_tag="dirichlet",
-            dirichlet_bc=TimeConstantGivenFunction(ConstantGivenFunction(0)),
-            neumann_tag="neumann",
-            neumann_bc=TimeConstantGivenFunction(ConstantGivenFunction(1))
-            )
-    u = discr.interpolate_volume_function(u0)
-
-    # diagnostics setup -------------------------------------------------------
-    from logpyle import LogManager, \
-            add_general_quantities, \
-            add_simulation_quantities, \
-            add_run_info
-
-    if write_output:
-        log_file_name = "heat.dat"
-    else:
-        log_file_name = None
-
-    logmgr = LogManager(log_file_name, "w", rcon.communicator)
-    add_run_info(logmgr)
-    add_general_quantities(logmgr)
-    add_simulation_quantities(logmgr)
-    discr.add_instrumentation(logmgr)
-
-    from grudge.log import LpNorm
-    u_getter = lambda: u
-    logmgr.add_quantity(LpNorm(u_getter, discr, 1, name="l1_u"))
-    logmgr.add_quantity(LpNorm(u_getter, discr, name="l2_u"))
-
-    logmgr.add_watches(["step.max", "t_sim.max", "l2_u", "t_step.max"])
-
-    # timestep loop -----------------------------------------------------------
-    from grudge.timestep.runge_kutta import LSRK4TimeStepper, ODE45TimeStepper
-    from grudge.timestep.dumka3 import Dumka3TimeStepper
-    #stepper = LSRK4TimeStepper()
-    stepper = Dumka3TimeStepper(3, rtol=1e-6, rcon=rcon,
-            vector_primitive_factory=discr.get_vector_primitive_factory(),
-            dtype=discr.default_scalar_type)
-    #stepper = ODE45TimeStepper(rtol=1e-6, rcon=rcon,
-            #vector_primitive_factory=discr.get_vector_primitive_factory(),
-            #dtype=discr.default_scalar_type)
-    stepper.add_instrumentation(logmgr)
-
-    rhs = op.bind(discr)
-    try:
-        next_dt = op.estimate_timestep(discr,
-                stepper=LSRK4TimeStepper(), t=0, fields=u)
-
-        from grudge.timestep import times_and_steps
-        step_it = times_and_steps(
-                final_time=0.1, logmgr=logmgr,
-                max_dt_getter=lambda t: next_dt,
-                taken_dt_getter=lambda: taken_dt)
-
-        for step, t, dt in step_it:
-            if step % 10 == 0 and write_output:
-                visf = vis.make_file("fld-%04d" % step)
-                vis.add_data(visf, [
-                    ("u", discr.convert_volume(u, kind="numpy")),
-                    ], time=t, step=step)
-                visf.close()
-
-            u, t, taken_dt, next_dt = stepper(u, t, next_dt, rhs)
-            #u = stepper(u, t, dt, rhs)
-
-        assert discr.norm(u) < 1
-    finally:
-        if write_output:
-            vis.close()
-
-        logmgr.close()
-        discr.close()
-
-
-
-
-if __name__ == "__main__":
-    main()
-
-
-
-
-# entry points for py.test ----------------------------------------------------
-from pytools.test import mark_test
-@mark_test.long
-def test_heat():
-    main(write_output=False)
diff --git a/unported-examples/maxwell/.gitignore b/unported-examples/maxwell/.gitignore
deleted file mode 100644
index 641163859ce66ad7bebf63db3035f0d58abef797..0000000000000000000000000000000000000000
--- a/unported-examples/maxwell/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-bessel_zeros.py
-2d_cavity
diff --git a/unported-examples/maxwell/generate-bessel-zeros.py b/unported-examples/maxwell/generate-bessel-zeros.py
deleted file mode 100644
index 172d491662a16a329850857af48ef5792eb2b18c..0000000000000000000000000000000000000000
--- a/unported-examples/maxwell/generate-bessel-zeros.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from __future__ import absolute_import
-from six.moves import range
-def main():
-    import scipy.special
-
-    maxnu = 10
-    n_zeros = 20
-
-    zeros = []
-    for n in range(0,maxnu+1):
-        zeros.append(list(scipy.special.jn_zeros(n, n_zeros)))
-
-    outf = open("bessel_zeros.py", "w").write("bessel_zeros = %s" % zeros)
-
-if __name__ == "__main__":
-    main()
diff --git a/unported-examples/maxwell/inhomogeneous_waveguide.mac b/unported-examples/maxwell/inhomogeneous_waveguide.mac
deleted file mode 100644
index 28a582f428be9e3193e4b012f4029d45464f0db9..0000000000000000000000000000000000000000
--- a/unported-examples/maxwell/inhomogeneous_waveguide.mac
+++ /dev/null
@@ -1,39 +0,0 @@
-
-/*
-From Robert E. Collin, Field Theory of Guided Waves, p. 413, chaper 6
-
-Find the cut-off frequency of a rectangular waveguide partially filled with a dielectric slab,
-in order to find the resonant frequency of an inhomogeneous 2D cavity.
-
-Take (5a), the transcendental equation for h and l, and substitute for their definitions in terms of gamma
-Then solve for the condition that gamma is 0, for the mode with m=0.
-t - width of dielectric section
-d - width of air section
-kappa - relative permittivity
-k_0 - free space wavenumber
-gamma - waveguide wavenumber
-l - transverse wavenumber in dielectric
-h - transverse wavenumber in air
-*/
-
-trans_eq : h*tan(l*t) + l*tan(h*d);
-l_gamma : sqrt(gamma^2 - (m*pi/b)^2 + kappa*k_0^2);
-h_gamma : sqrt(gamma^2 - (m*pi/b)^2 + k_0^2);
-l_simp : l_gamma, gamma=0, m=0;
-h_simp : h_gamma, gamma=0, m=0;
-
-subst(h_gamma, h, trans_eq)$
-subst(l_gamma, l, %)$
-subst(0, m, %)$
-trans_eq2 : subst(0, gamma, %);
-
-c : 2.99792458e8$
-plot2d([trans_eq2], [f,0.1e9,1.4e9], [y, -1000, 1000]), t = 50e-3, d=100e-3, kappa=2, k_0 = 2*%pi*f/c$
-f_sol : find_root(trans_eq2, f, 0.8e9, 1e9), t = 50e-3, d = 100e-3, kappa = 2, k_0 = 2*%pi*f/c;
-h_simp: float(2*%pi*f_sol/c);
-sqrt(kappa)*2*%pi*f_sol/c, kappa=2$
-l_simp: float(%);
-
-%pi*a/(a-d-sqrt(kappa)), a=150e-3, d=100e-3, kappa=2;
-float(%);
-
diff --git a/unported-examples/maxwell/maxwell-2d.py b/unported-examples/maxwell/maxwell-2d.py
deleted file mode 100644
index 802f391cdeb6516a8d0bd502a3f72c990134f049..0000000000000000000000000000000000000000
--- a/unported-examples/maxwell/maxwell-2d.py
+++ /dev/null
@@ -1,164 +0,0 @@
-__copyright__ = "Copyright (C) 2007 Andreas Kloeckner"
-
-__license__ = """
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-"""
-
-
-"Maxwell's equation example with fixed material coefficients"
-
-
-from __future__ import division
-from __future__ import absolute_import
-from __future__ import print_function
-import numpy.linalg as la
-
-
-def main(write_output=True):
-    from math import sqrt, pi, exp
-    from os.path import join
-
-    from grudge.backends import guess_run_context
-    rcon = guess_run_context()
-
-    epsilon0 = 8.8541878176e-12 # C**2 / (N m**2)
-    mu0 = 4*pi*1e-7 # N/A**2.
-    epsilon = 1*epsilon0
-    mu = 1*mu0
-
-    output_dir = "maxwell-2d"
-    import os
-    if not os.access(output_dir, os.F_OK):
-        os.makedirs(output_dir)
-
-    from grudge.mesh.generator import make_disk_mesh
-    mesh = make_disk_mesh(r=0.5, max_area=1e-3)
-
-    if rcon.is_head_rank:
-        mesh_data = rcon.distribute_mesh(mesh)
-    else:
-        mesh_data = rcon.receive_mesh()
-
-    class CurrentSource:
-        shape = (3,)
-
-        def __call__(self, x, el):
-            return [0,0,exp(-80*la.norm(x))]
-
-    order = 3
-    final_time = 1e-8
-    discr = rcon.make_discretization(mesh_data, order=order,
-            debug=["cuda_no_plan"])
-
-    from grudge.visualization import VtkVisualizer
-    if write_output:
-        vis = VtkVisualizer(discr, rcon, join(output_dir, "em-%d" % order))
-
-    if rcon.is_head_rank:
-        print("order %d" % order)
-        print("#elements=", len(mesh.elements))
-
-    from grudge.mesh import BTAG_ALL, BTAG_NONE
-    from grudge.models.em import TMMaxwellOperator
-    from grudge.data import make_tdep_given, TimeIntervalGivenFunction
-    op = TMMaxwellOperator(epsilon, mu, flux_type=1,
-            current=TimeIntervalGivenFunction(
-                make_tdep_given(CurrentSource()), off_time=final_time/10),
-            absorb_tag=BTAG_ALL, pec_tag=BTAG_NONE)
-    fields = op.assemble_eh(discr=discr)
-
-    from grudge.timestep import LSRK4TimeStepper
-    stepper = LSRK4TimeStepper()
-    from time import time
-    last_tstep = time()
-    t = 0
-
-    # diagnostics setup ---------------------------------------------------
-    from logpyle import LogManager, add_general_quantities, \
-            add_simulation_quantities, add_run_info
-
-    if write_output:
-        log_file_name = join(output_dir, "maxwell-%d.dat" % order)
-    else:
-        log_file_name = None
-
-    logmgr = LogManager(log_file_name, "w", rcon.communicator)
-    add_run_info(logmgr)
-    add_general_quantities(logmgr)
-    add_simulation_quantities(logmgr)
-    discr.add_instrumentation(logmgr)
-    stepper.add_instrumentation(logmgr)
-
-    from logpyle import IntervalTimer
-    vis_timer = IntervalTimer("t_vis", "Time spent visualizing")
-    logmgr.add_quantity(vis_timer)
-
-    from grudge.log import EMFieldGetter, add_em_quantities
-    field_getter = EMFieldGetter(discr, op, lambda: fields)
-    add_em_quantities(logmgr, op, field_getter)
-
-    logmgr.add_watches(["step.max", "t_sim.max",
-        ("W_field", "W_el+W_mag"), "t_step.max"])
-
-    # timestep loop -------------------------------------------------------
-    rhs = op.bind(discr)
-
-    try:
-        from grudge.timestep import times_and_steps
-        step_it = times_and_steps(
-                final_time=final_time, logmgr=logmgr,
-                max_dt_getter=lambda t: op.estimate_timestep(discr,
-                    stepper=stepper, t=t, fields=fields))
-
-        for step, t, dt in step_it:
-            if step % 10 == 0 and write_output:
-                e, h = op.split_eh(fields)
-                visf = vis.make_file(join(output_dir, "em-%d-%04d" % (order, step)))
-                vis.add_data(visf,
-                        [
-                            ("e", discr.convert_volume(e, "numpy")),
-                            ("h", discr.convert_volume(h, "numpy")),
-                            ],
-                        time=t, step=step
-                        )
-                visf.close()
-
-            fields = stepper(fields, t, dt, rhs)
-
-        assert discr.norm(fields) < 0.03
-    finally:
-        if write_output:
-            vis.close()
-
-        logmgr.close()
-        discr.close()
-
-if __name__ == "__main__":
-    import cProfile as profile
-    #profile.run("main()", "wave2d.prof")
-    main()
-
-
-
-
-# entry points for py.test ----------------------------------------------------
-from pytools.test import mark_test
-@mark_test.long
-def test_maxwell_2d():
-    main(write_output=False)
diff --git a/unported-examples/maxwell/maxwell-pml.py b/unported-examples/maxwell/maxwell-pml.py
deleted file mode 100644
index 2af61e4ae288cde9f6eb159a1fdb57754fe41bb9..0000000000000000000000000000000000000000
--- a/unported-examples/maxwell/maxwell-pml.py
+++ /dev/null
@@ -1,244 +0,0 @@
-
-from __future__ import division
-from __future__ import absolute_import
-from __future__ import print_function
-
-__copyright__ = "Copyright (C) 2007 Andreas Kloeckner"
-
-__license__ = """
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-"""
-
-
-
-
-import numpy as np
-
-
-
-
-def make_mesh(a, b, pml_width=0.25, **kwargs):
-    from meshpy.geometry import GeometryBuilder, make_circle
-    geob = GeometryBuilder()
-
-    circle_centers = [(-1.5, 0), (1.5, 0)]
-    for cent in circle_centers:
-        geob.add_geometry(*make_circle(1, cent))
-
-    geob.wrap_in_box(1)
-    geob.wrap_in_box(pml_width)
-
-    mesh_mod = geob.mesher_module()
-    mi = mesh_mod.MeshInfo()
-    geob.set(mi)
-
-    mi.set_holes(circle_centers)
-
-    built_mi = mesh_mod.build(mi, **kwargs)
-
-    def boundary_tagger(fvi, el, fn, points):
-        return []
-
-    from grudge.mesh import make_conformal_mesh_ext
-    from grudge.mesh.element import Triangle
-    pts = np.asarray(built_mi.points, dtype=np.float64)
-    return make_conformal_mesh_ext(
-            pts,
-            [Triangle(i, el, pts)
-                for i, el in enumerate(built_mi.elements)],
-            boundary_tagger)
-
-
-
-
-def main(write_output=True):
-    from grudge.timestep.runge_kutta import LSRK4TimeStepper
-    from math import sqrt, pi, exp
-
-    from grudge.backends import guess_run_context
-    rcon = guess_run_context()
-
-    epsilon0 = 8.8541878176e-12 # C**2 / (N m**2)
-    mu0 = 4*pi*1e-7 # N/A**2.
-    epsilon = 1*epsilon0
-    mu = 1*mu0
-
-    c = 1/sqrt(mu*epsilon)
-
-    pml_width = 0.5
-    #mesh = make_mesh(a=np.array((-1,-1,-1)), b=np.array((1,1,1)),
-    #mesh = make_mesh(a=np.array((-3,-3)), b=np.array((3,3)),
-    mesh = make_mesh(a=np.array((-1,-1)), b=np.array((1,1)),
-    #mesh = make_mesh(a=np.array((-2,-2)), b=np.array((2,2)),
-            pml_width=pml_width, max_volume=0.01)
-
-    if rcon.is_head_rank:
-        mesh_data = rcon.distribute_mesh(mesh)
-    else:
-        mesh_data = rcon.receive_mesh()
-
-    class Current:
-        def volume_interpolant(self, t, discr):
-            from grudge.tools import make_obj_array
-
-            result = discr.volume_zeros(kind="numpy", dtype=np.float64)
-
-            omega = 6*c
-            if omega*t > 2*pi:
-                return make_obj_array([result, result, result])
-
-            x = make_obj_array(discr.nodes.T)
-            r = np.sqrt(np.dot(x, x))
-
-            idx = r<0.3
-            result[idx] = (1+np.cos(pi*r/0.3))[idx] \
-                    *np.sin(omega*t)**3
-
-            result = discr.convert_volume(result, kind=discr.compute_kind,
-                    dtype=discr.default_scalar_type)
-            return make_obj_array([-result, result, result])
-
-    order = 3
-    discr = rcon.make_discretization(mesh_data, order=order,
-            debug=["cuda_no_plan"])
-
-    from grudge.visualization import VtkVisualizer
-    if write_output:
-        vis = VtkVisualizer(discr, rcon, "em-%d" % order)
-
-    from grudge.mesh import BTAG_ALL, BTAG_NONE
-    from grudge.data import GivenFunction, TimeHarmonicGivenFunction, TimeIntervalGivenFunction
-    from grudge.models.em import MaxwellOperator
-    from grudge.models.pml import \
-            AbarbanelGottliebPMLMaxwellOperator, \
-            AbarbanelGottliebPMLTMMaxwellOperator, \
-            AbarbanelGottliebPMLTEMaxwellOperator
-
-    op = AbarbanelGottliebPMLTEMaxwellOperator(epsilon, mu, flux_type=1,
-            current=Current(),
-            pec_tag=BTAG_ALL,
-            absorb_tag=BTAG_NONE,
-            add_decay=True
-            )
-
-    fields = op.assemble_ehpq(discr=discr)
-
-    stepper = LSRK4TimeStepper()
-
-    if rcon.is_head_rank:
-        print("order %d" % order)
-        print("#elements=", len(mesh.elements))
-
-    # diagnostics setup ---------------------------------------------------
-    from logpyle import LogManager, add_general_quantities, \
-            add_simulation_quantities, add_run_info
-
-    if write_output:
-        log_file_name = "maxwell-%d.dat" % order
-    else:
-        log_file_name = None
-
-    logmgr = LogManager(log_file_name, "w", rcon.communicator)
-    add_run_info(logmgr)
-    add_general_quantities(logmgr)
-    add_simulation_quantities(logmgr)
-    discr.add_instrumentation(logmgr)
-    stepper.add_instrumentation(logmgr)
-
-    from logpyle import IntervalTimer
-    vis_timer = IntervalTimer("t_vis", "Time spent visualizing")
-    logmgr.add_quantity(vis_timer)
-
-    from grudge.log import EMFieldGetter, add_em_quantities
-    field_getter = EMFieldGetter(discr, op, lambda: fields)
-    add_em_quantities(logmgr, op, field_getter)
-
-    logmgr.add_watches(["step.max", "t_sim.max", ("W_field", "W_el+W_mag"), "t_step.max"])
-
-    from grudge.log import LpNorm
-    class FieldIdxGetter:
-        def __init__(self, whole_getter, idx):
-            self.whole_getter = whole_getter
-            self.idx = idx
-
-        def __call__(self):
-            return self.whole_getter()[self.idx]
-
-    # timestep loop -------------------------------------------------------
-
-    t = 0
-    pml_coeff = op.coefficients_from_width(discr, width=pml_width)
-    rhs = op.bind(discr, pml_coeff)
-
-    try:
-        from grudge.timestep import times_and_steps
-        step_it = times_and_steps(
-                final_time=4/c, logmgr=logmgr,
-                max_dt_getter=lambda t: op.estimate_timestep(discr,
-                    stepper=stepper, t=t, fields=fields))
-
-        for step, t, dt in step_it:
-            if step % 10 == 0 and write_output:
-                e, h, p, q = op.split_ehpq(fields)
-                visf = vis.make_file("em-%d-%04d" % (order, step))
-                #pml_rhs_e, pml_rhs_h, pml_rhs_p, pml_rhs_q = \
-                        #op.split_ehpq(rhs(t, fields))
-                j = Current().volume_interpolant(t, discr)
-                vis.add_data(visf, [
-                    ("e", discr.convert_volume(e, "numpy")),
-                    ("h", discr.convert_volume(h, "numpy")),
-                    ("p", discr.convert_volume(p, "numpy")),
-                    ("q", discr.convert_volume(q, "numpy")),
-                    ("j", discr.convert_volume(j, "numpy")),
-                    #("pml_rhs_e", pml_rhs_e),
-                    #("pml_rhs_h", pml_rhs_h),
-                    #("pml_rhs_p", pml_rhs_p),
-                    #("pml_rhs_q", pml_rhs_q),
-                    #("max_rhs_e", max_rhs_e),
-                    #("max_rhs_h", max_rhs_h),
-                    #("max_rhs_p", max_rhs_p),
-                    #("max_rhs_q", max_rhs_q),
-                    ],
-                    time=t, step=step)
-                visf.close()
-
-            fields = stepper(fields, t, dt, rhs)
-
-        _, _, energies_data = logmgr.get_expr_dataset("W_el+W_mag")
-        energies = [value for tick_nbr, value in energies_data]
-
-        assert energies[-1] < max(energies) * 1e-2
-
-    finally:
-        logmgr.close()
-
-        if write_output:
-            vis.close()
-
-if __name__ == "__main__":
-    main()
-
-
-
-
-# entry points for py.test ----------------------------------------------------
-from pytools.test import mark_test
-@mark_test.long
-def test_maxwell_pml():
-    main(write_output=False)
diff --git a/unported-examples/maxwell/notes.tm b/unported-examples/maxwell/notes.tm
deleted file mode 100644
index bd159b24881bbcbd6561a2e048c8cd43dff79f8f..0000000000000000000000000000000000000000
--- a/unported-examples/maxwell/notes.tm
+++ /dev/null
@@ -1,403 +0,0 @@
-<TeXmacs|1.0.6>
-
-<style|<tuple|generic|maxima|axiom>>
-
-<\body>
-  <section|Cylindrical TM Maxwell Cavity Mode>
-
-  <with|prog-language|axiom|prog-session|default|<\session>
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      )clear all
-    </input>
-
-    <\output>
-      \ \ \ All user variables and function definitions have been cleared.
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      )library )dir "/home/andreas/axiom"
-    </input>
-
-    <\output>
-      \ \ \ TexFormat is already explicitly exposed in frame initial\ 
-
-      \ \ \ TexFormat will be automatically loaded when needed from\ 
-
-      \ \ \ \ \ \ /home/andreas/axiom/TEX.NRLIB/code
-
-      \ \ \ TexFormat1 is already explicitly exposed in frame initial\ 
-
-      \ \ \ TexFormat1 will be automatically loaded when needed from\ 
-
-      \ \ \ \ \ \ /home/andreas/axiom/TEX1.NRLIB/code
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      J:=operator 'J
-    </input>
-
-    <\output>
-      <with|mode|math|math-display|true|J<leqno>(1)>
-
-      <axiomtype|BasicOperator >
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      psi(rho,phi) == J(gamma*rho)*exp(PP*%i*m*phi)
-    </input>
-
-    <\output>
-      <axiomtype|Void >
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      psiglob:=psi(sqrt(x^2+y^2),atan(y/x))
-    </input>
-
-    <\output>
-      \ \ \ Compiling function psi with type (Expression Integer,Expression\ 
-
-      \ \ \ \ \ \ Integer) -\<gtr\> Expression Complex Integer\ 
-
-      <with|mode|math|math-display|true|J<left|(>\<gamma\><sqrt|y<rsup|2>+x<rsup|2>><right|)>e<rsup|<left|(>i*P*P*m*arctan
-      <left|(><frac|y|x><right|)><right|)>><leqno>(3)>
-
-      <axiomtype|Expression Complex Integer >
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      D(psi(rho,phi),rho)
-    </input>
-
-    <\output>
-      \ \ \ Compiling function psi with type (Variable rho,Variable phi)
-      -\<gtr\>\ 
-
-      \ \ \ \ \ \ Expression Complex Integer\ 
-
-      <with|mode|math|math-display|true|\<gamma\>e<rsup|<left|(>i*P*P*m\<phi\><right|)>>J<rsub|
-      ><rsup|,><left|(>\<gamma\>\<rho\><right|)><leqno>(5)>
-
-      <axiomtype|Expression Complex Integer >
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      cross(vector [0,0,1], vector [x,y,0])
-    </input>
-
-    <\output>
-      <with|mode|math|math-display|true|<left|[>-y,<space|0.5spc>x,<space|0.5spc>0<right|]><leqno>(7)>
-
-      <axiomtype|Vector Polynomial Integer >
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      \;
-    </input>
-  </session>>
-
-  <section|Rectangular Cavity Mode>
-
-  According to Jackson, p. 357, (8.17), we need to solve the Helmholtz
-  equation
-
-  <\eqnarray*>
-    <tformat|<cwith|1|1|3|3|cell-halign|l>|<table|<row|<cell|(\<nabla\><rsup|2>+\<mu\>\<varepsilon\>\<omega\><rsup|2>)<matrix|<tformat|<table|<row|<cell|\<b-E\>>>|<row|<cell|\<b-B\>>>>>>>|<cell|=>|<cell|\<b-0\>,>>>>
-  </eqnarray*>
-
-  subject to <with|mode|math|n\<times\>\<b-E\>=0> and
-  <with|mode|math|n\<cdot\>\<b-B\>=0>. The ansatz is
-
-  <\equation*>
-    \<b-E\>=<matrix|<tformat|<table|<row|<cell|E<rsub|x,x>(x)E<rsub|x,y>(y)E<rsub|x,z>(z)>>|<row|<cell|E<rsub|y,x>(x)E<rsub|y,y>(y)E<rsub|y,z>(z)>>|<row|<cell|E<rsub|z,x>(x)E<rsub|z,y>(y)E<rsub|z,z>(z)>>>>>
-  </equation*>
-
-  and likewise for <with|mode|math|\<b-B\>>. The boundary conditions are
-
-  <\eqnarray*>
-    <tformat|<table|<row|<cell|E<rsub|x>(x,<with|math-level|1|<tabular|<tformat|<table|<row|<cell|0>>|<row|<cell|b>>>>>>,z)>|<cell|=>|<cell|0,>>|<row|<cell|E<rsub|x>(x,y,<with|math-level|1|<tabular|<tformat|<table|<row|<cell|0>>|<row|<cell|c>>>>>>)>|<cell|=>|<cell|0,>>>>
-  </eqnarray*>
-
-  and so on, as well as
-
-  <\eqnarray*>
-    <tformat|<table|<row|<cell|H<rsub|x>(<with|math-level|1|<tabular|<tformat|<table|<row|<cell|0>>|<row|<cell|a>>>>>>,y,z)>|<cell|=>|<cell|0.>>>>
-  </eqnarray*>
-
-  So
-
-  <\equation*>
-    E<rsub|x>=\<alpha\><rsub|x>exp(i\<beta\><rsub|x>x)sin<left|(><frac|n\<pi\>y|b><right|)>sin<left|(><frac|o\<pi\>z|c><right|)>exp(-i\<omega\>t)=\<alpha\><rsub|x>e<rsub|x>s<rsub|y>s<rsub|z>
-  </equation*>
-
-  and analogous terms for <with|mode|math|E<rsub|y>> and
-  <with|mode|math|E<rsub|z>> satisfy the first batch of boundary conditions.
-  Because of the Helmholtz equation, we find that
-  <with|mode|math|\<beta\><rsub|x>=m\<pi\>/a>; otherwise, not all vector
-  components would share the same eigenvalue, which would not solve the
-  equation.
-
-  <with|prog-language|axiom|prog-session|default|<\session>
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      )clear all
-    </input>
-
-    <\output>
-      \ \ \ All user variables and function definitions have been cleared.
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      )library )dir "/home/andreas/axiom"
-    </input>
-
-    <\output>
-      \ \ \ TexFormat is already explicitly exposed in frame initial\ 
-
-      \ \ \ TexFormat will be automatically loaded when needed from\ 
-
-      \ \ \ \ \ \ /home/andreas/axiom/TEX.NRLIB/code
-
-      \ \ \ TexFormat1 is already explicitly exposed in frame initial\ 
-
-      \ \ \ TexFormat1 will be automatically loaded when needed from\ 
-
-      \ \ \ \ \ \ /home/andreas/axiom/TEX1.NRLIB/code
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      factors:=[f,g,h];
-    </input>
-
-    <\output>
-      <axiomtype|List OrderedVariableList [f,g,h] >
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      coord := [x,y,z];
-    </input>
-
-    <\output>
-      <axiomtype|List OrderedVariableList [x,y,z] >
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      curl(v)== vector [D(v.3,y)-D(v.2,z),D(v.1,z)-D(v.3,x),D(v.2,x)-D(v.1,y)];
-    </input>
-
-    <\output>
-      <axiomtype|Void >
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      c:=1/sqrt(epsilon*mu);
-    </input>
-
-    <\output>
-      <axiomtype|Expression Integer >
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      sines(i) == sin(factors.i*coord.i);
-    </input>
-
-    <\output>
-      <axiomtype|Void >
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      cosines(i) == cos(factors.i*coord.i);
-    </input>
-
-    <\output>
-      <axiomtype|Void >
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      k:=sqrt(f^2+g^2+h^2);omega:=k*c;
-    </input>
-
-    <\output>
-      <axiomtype|Expression Integer >
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      zdep1:=exp(%i*h*z); zdep2:=exp(-%i*h*z);
-    </input>
-
-    <\output>
-      <axiomtype|Expression Complex Integer >
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      zf1:=1; zf2:=-1;
-    </input>
-
-    <\output>
-      <axiomtype|Integer >
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      zdep:=zf1*zdep1 + zf2*zdep2;
-    </input>
-
-    <\output>
-      <axiomtype|Expression Complex Integer >
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      C:=%i/(f^2+g^2);
-    </input>
-
-    <\output>
-      <axiomtype|Fraction Polynomial Complex Integer >
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      efield := vector [
-
-      C*f*h*cosines(1)* \ sines(2)*(zf1*zdep1-zf2*zdep2),
-
-      C*g*h* \ sines(1)*cosines(2)*(zf1*zdep1-zf2*zdep2),
-
-      \ \ \ \ \ \ \ \ sines(1)* \ sines(2)*zdep];
-    </input>
-
-    <\output>
-      <axiomtype|Vector Expression Complex Integer >
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      hfield:=1/(-%i*omega*mu)*(-curl efield);
-    </input>
-
-    <\output>
-      <axiomtype|Vector Expression Complex Integer >
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      efield2:=1/(-%i*omega*epsilon)*(curl hfield);
-    </input>
-
-    <\output>
-      <axiomtype|Vector Expression Complex Integer >
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      efield2-efield
-    </input>
-
-    <\output>
-      <with|mode|math|math-display|true|<left|[>0,<space|0.5spc>0,<space|0.5spc>0<right|]><leqno>(71)>
-
-      <axiomtype|Vector Expression Complex Integer >
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      hfield
-    </input>
-
-    <\output>
-      <with|mode|math|math-display|true|<left|[><frac|<left|(><left|(>-i*g*h<rsup|2>-i*g<rsup|3>-i*f<rsup|2>g<right|)>cos
-      <left|(>g*y<right|)>e<rsup|<left|(>i*h*z<right|)>>+<left|(>i*g*h<rsup|2>+i*g<rsup|3>+i*f<rsup|2>g<right|)>cos
-      <left|(>g*y<right|)>e<rsup|<left|(>-i*h*z<right|)>><right|)>sin
-      <left|(>f*x<right|)><sqrt|\<epsilon\>\<mu\>>|<left|(>g<rsup|2>+f<rsup|2><right|)>\<mu\><sqrt|h<rsup|2>+g<rsup|2>+f<rsup|2>>>,<space|0.5spc><frac|<left|(><left|(>i*f*h<rsup|2>+i*f*g<rsup|2>+i*f<rsup|3><right|)>cos
-      <left|(>f*x<right|)>e<rsup|<left|(>i*h*z<right|)>>+<left|(>-i*f*h<rsup|2>-i*f*g<rsup|2>-i*f<rsup|3><right|)>cos
-      <left|(>f*x<right|)>e<rsup|<left|(>-i*h*z<right|)>><right|)>sin
-      <left|(>g*y<right|)><sqrt|\<epsilon\>\<mu\>>|<left|(>g<rsup|2>+f<rsup|2><right|)>\<mu\><sqrt|h<rsup|2>+g<rsup|2>+f<rsup|2>>>,<space|0.5spc>0<right|]><leqno>(72)>
-
-      <axiomtype|Vector Expression Complex Integer >
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      hfield2:=vector [
-
-      -%i*g/(f^2+g^2)*epsilon*omega*sines(1)*cosines(2)*(zf1*zdep1+zf2*zdep2),
-
-      \ %i*f/(f^2+g^2)*epsilon*omega*cosines(1)*sines(2)*(zf1*zdep1+zf2*zdep2),
-
-      0]
-    </input>
-
-    <\output>
-      <with|mode|math|math-display|true|<left|[><frac|<left|(>-i\<epsilon\>g*cos
-      <left|(>g*y<right|)>e<rsup|<left|(>i*h*z<right|)>>+i\<epsilon\>g*cos
-      <left|(>g*y<right|)>e<rsup|<left|(>-i*h*z<right|)>><right|)>sin
-      <left|(>f*x<right|)><sqrt|h<rsup|2>+g<rsup|2>+f<rsup|2>>|<left|(>g<rsup|2>+f<rsup|2><right|)><sqrt|\<epsilon\>\<mu\>>>,<space|0.5spc><frac|<left|(>i\<epsilon\>f*cos
-      <left|(>f*x<right|)>e<rsup|<left|(>i*h*z<right|)>>-i\<epsilon\>f*cos
-      <left|(>f*x<right|)>e<rsup|<left|(>-i*h*z<right|)>><right|)>sin
-      <left|(>g*y<right|)><sqrt|h<rsup|2>+g<rsup|2>+f<rsup|2>>|<left|(>g<rsup|2>+f<rsup|2><right|)><sqrt|\<epsilon\>\<mu\>>>,<space|0.5spc>0<right|]><leqno>(73)>
-
-      <axiomtype|Vector Expression Complex Integer >
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      hfield-hfield2
-    </input>
-
-    <\output>
-      <with|mode|math|math-display|true|<left|[>0,<space|0.5spc>0,<space|0.5spc>0<right|]><leqno>(74)>
-
-      <axiomtype|Vector Expression Complex Integer >
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      bcs:=[
-
-      eval(efield.1, z=0),
-
-      eval(efield.2, z=0),
-
-      eval(efield.3, z=0),
-
-      eval(hfield.1, x=0),
-
-      eval(hfield.2, y=0),
-
-      eval(hfield.3, z=0)
-
-      ]
-    </input>
-
-    <\output>
-      <with|mode|math|math-display|true|<left|[>0,<space|0.5spc><frac|2i*g*h*cos
-      <left|(>g*y<right|)>sin <left|(>f*x<right|)>|g<rsup|2>+f<rsup|2>>,<space|0.5spc><frac|2i*f*h*cos
-      <left|(>f*x<right|)>sin <left|(>g*y<right|)>|g<rsup|2>+f<rsup|2>>,<space|0.5spc>0,<space|0.5spc>0,<space|0.5spc>0<right|]><leqno>(76)>
-
-      <axiomtype|List Expression Complex Integer >
-    </output>
-
-    <\input|<with|color|red|<with|mode|math|\<rightarrow\>> >>
-      \;
-    </input>
-  </session>>
-
-  \;
-</body>
-
-<\initial>
-  <\collection>
-    <associate|page-type|letter>
-  </collection>
-</initial>
-
-<\references>
-  <\collection>
-    <associate|auto-1|<tuple|1|1>>
-    <associate|auto-2|<tuple|2|1>>
-    <associate|auto-3|<tuple|3|?>>
-  </collection>
-</references>
-
-<\auxiliary>
-  <\collection>
-    <\associate|toc>
-      <vspace*|1fn><with|font-series|<quote|bold>|math-font-series|<quote|bold>|Cylindrical
-      TM Maxwell Cavity Mode> <datoms|<macro|x|<repeat|<arg|x>|<with|font-series|medium|<with|font-size|1|<space|0.2fn>.<space|0.2fn>>>>>|<htab|5mm>>
-      <no-break><pageref|auto-1><vspace|0.5fn>
-
-      <vspace*|1fn><with|font-series|<quote|bold>|math-font-series|<quote|bold>|Rectangular
-      Cavity Mode> <datoms|<macro|x|<repeat|<arg|x>|<with|font-series|medium|<with|font-size|1|<space|0.2fn>.<space|0.2fn>>>>>|<htab|5mm>>
-      <no-break><pageref|auto-2><vspace|0.5fn>
-    </associate>
-  </collection>
-</auxiliary>
\ No newline at end of file
diff --git a/unported-examples/poisson/helmholtz.py b/unported-examples/poisson/helmholtz.py
deleted file mode 100644
index 1814e192265f3a95e623fdf5810ba750869fbe95..0000000000000000000000000000000000000000
--- a/unported-examples/poisson/helmholtz.py
+++ /dev/null
@@ -1,185 +0,0 @@
-__copyright__ = "Copyright (C) 2007 Andreas Kloeckner"
-
-__license__ = """
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-"""
-
-
-from __future__ import division
-from __future__ import absolute_import
-from __future__ import print_function
-import numpy
-import numpy.linalg as la
-from grudge.tools import Reflection, Rotation
-
-
-
-class ResidualPrinter:
-    def __init__(self, compute_resid=None):
-        self.count = 0
-        self.compute_resid = compute_resid
-
-    def __call__(self, cur_sol):
-        import sys
-
-        if cur_sol is not None:
-            if self.count % 20 == 0:
-                sys.stdout.write("IT %8d %g                 \r" % (
-                    self.count, la.norm(self.compute_resid(cur_sol))))
-        else:
-            sys.stdout.write("IT %8d                    \r" % self.count)
-        self.count += 1
-        sys.stdout.flush()
-
-
-
-
-def main(write_output=True):
-    from grudge.data import GivenFunction, ConstantGivenFunction
-
-    from grudge.backends import guess_run_context
-    rcon = guess_run_context()
-
-    dim = 2
-
-    def boundary_tagger(fvi, el, fn, points):
-        from math import atan2, pi
-        normal = el.face_normals[fn]
-        if -90/180*pi < atan2(normal[1], normal[0]) < 90/180*pi:
-            return ["neumann"]
-        else:
-            return ["dirichlet"]
-
-    def dirichlet_boundary_tagger(fvi, el, fn, points):
-            return ["dirichlet"]
-
-    if dim == 2:
-        if rcon.is_head_rank:
-            from grudge.mesh.generator import make_disk_mesh
-            mesh = make_disk_mesh(r=0.5,
-                    boundary_tagger=dirichlet_boundary_tagger,
-                    max_area=1e-3)
-    elif dim == 3:
-        if rcon.is_head_rank:
-            from grudge.mesh.generator import make_ball_mesh
-            mesh = make_ball_mesh(max_volume=0.0001,
-                    boundary_tagger=lambda fvi, el, fn, points:
-                    ["dirichlet"])
-    else:
-        raise RuntimeError("bad number of dimensions")
-
-    if rcon.is_head_rank:
-        print("%d elements" % len(mesh.elements))
-        mesh_data = rcon.distribute_mesh(mesh)
-    else:
-        mesh_data = rcon.receive_mesh()
-
-    discr = rcon.make_discretization(mesh_data, order=5,
-            debug=[])
-
-    def dirichlet_bc(x, el):
-        from math import sin
-        return sin(10*x[0])
-
-    def rhs_c(x, el):
-        if la.norm(x) < 0.1:
-            return 1000
-        else:
-            return 0
-
-    def my_diff_tensor():
-        result = numpy.eye(dim)
-        result[0,0] = 0.1
-        return result
-
-    try:
-        from grudge.models.poisson import (
-                PoissonOperator,
-                HelmholtzOperator)
-        from grudge.second_order import \
-                IPDGSecondDerivative, LDGSecondDerivative, \
-                StabilizedCentralSecondDerivative
-
-        k = 1
-
-        from grudge.mesh import BTAG_NONE, BTAG_ALL
-        op = HelmholtzOperator(k, discr.dimensions,
-                #diffusion_tensor=my_diff_tensor(),
-
-                #dirichlet_tag="dirichlet",
-                #neumann_tag="neumann",
-
-                dirichlet_tag=BTAG_ALL,
-                neumann_tag=BTAG_NONE,
-
-                #dirichlet_tag=BTAG_ALL,
-                #neumann_tag=BTAG_NONE,
-
-                #dirichlet_bc=GivenFunction(dirichlet_bc),
-                dirichlet_bc=ConstantGivenFunction(0),
-                neumann_bc=ConstantGivenFunction(-10),
-
-                scheme=StabilizedCentralSecondDerivative(),
-                #scheme=LDGSecondDerivative(),
-                #scheme=IPDGSecondDerivative(),
-                )
-        bound_op = op.bind(discr)
-
-        if False:
-            from grudge.iterative import parallel_cg
-            u = -parallel_cg(rcon, -bound_op,
-                    bound_op.prepare_rhs(discr.interpolate_volume_function(rhs_c)),
-                    debug=20, tol=5e-4,
-                    dot=discr.nodewise_dot_product,
-                    x=discr.volume_zeros())
-        else:
-            rhs = bound_op.prepare_rhs(discr.interpolate_volume_function(rhs_c))
-            def compute_resid(x):
-                return bound_op(x)-rhs
-
-            from scipy.sparse.linalg import minres, LinearOperator
-            u, info = minres(
-                    LinearOperator(
-                        (len(discr), len(discr)),
-                        matvec=bound_op, dtype=bound_op.dtype),
-                    rhs,
-                    callback=ResidualPrinter(compute_resid),
-                    tol=1e-5)
-            print()
-            if info != 0:
-                raise RuntimeError("gmres reported error %d" % info)
-            print("finished gmres")
-
-            print(la.norm(bound_op(u)-rhs)/la.norm(rhs))
-
-        if write_output:
-            from grudge.visualization import SiloVisualizer, VtkVisualizer
-            vis = VtkVisualizer(discr, rcon)
-            visf = vis.make_file("fld")
-            vis.add_data(visf, [ ("sol", discr.convert_volume(u, kind="numpy")), ])
-            visf.close()
-    finally:
-        discr.close()
-
-
-
-
-
-if __name__ == "__main__":
-    main()
diff --git a/unported-examples/poisson/poisson.py b/unported-examples/poisson/poisson.py
deleted file mode 100644
index 58bb530ccb401d2ac1c09756750a9f7f096c1e26..0000000000000000000000000000000000000000
--- a/unported-examples/poisson/poisson.py
+++ /dev/null
@@ -1,145 +0,0 @@
-__copyright__ = "Copyright (C) 2007 Andreas Kloeckner"
-
-__license__ = """
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-"""
-
-
-from __future__ import division
-from __future__ import absolute_import
-from __future__ import print_function
-import numpy
-import numpy.linalg as la
-from grudge.tools import Reflection, Rotation
-
-
-
-
-def main(write_output=True):
-    from grudge.data import GivenFunction, ConstantGivenFunction
-
-    from grudge.backends import guess_run_context
-    rcon = guess_run_context()
-
-    dim = 2
-
-    def boundary_tagger(fvi, el, fn, points):
-        from math import atan2, pi
-        normal = el.face_normals[fn]
-        if -90/180*pi < atan2(normal[1], normal[0]) < 90/180*pi:
-            return ["neumann"]
-        else:
-            return ["dirichlet"]
-
-    if dim == 2:
-        if rcon.is_head_rank:
-            from grudge.mesh.generator import make_disk_mesh
-            mesh = make_disk_mesh(r=0.5, boundary_tagger=boundary_tagger,
-                    max_area=1e-2)
-    elif dim == 3:
-        if rcon.is_head_rank:
-            from grudge.mesh.generator import make_ball_mesh
-            mesh = make_ball_mesh(max_volume=0.0001,
-                    boundary_tagger=lambda fvi, el, fn, points:
-                    ["dirichlet"])
-    else:
-        raise RuntimeError("bad number of dimensions")
-
-    if rcon.is_head_rank:
-        print("%d elements" % len(mesh.elements))
-        mesh_data = rcon.distribute_mesh(mesh)
-    else:
-        mesh_data = rcon.receive_mesh()
-
-    discr = rcon.make_discretization(mesh_data, order=5,
-            debug=[])
-
-    def dirichlet_bc(x, el):
-        from math import sin
-        return sin(10*x[0])
-
-    def rhs_c(x, el):
-        if la.norm(x) < 0.1:
-            return 1000
-        else:
-            return 0
-
-    def my_diff_tensor():
-        result = numpy.eye(dim)
-        result[0,0] = 0.1
-        return result
-
-    try:
-        from grudge.models.poisson import PoissonOperator
-        from grudge.second_order import \
-                IPDGSecondDerivative, LDGSecondDerivative, \
-                StabilizedCentralSecondDerivative
-        from grudge.mesh import BTAG_NONE, BTAG_ALL
-        op = PoissonOperator(discr.dimensions,
-                diffusion_tensor=my_diff_tensor(),
-
-                #dirichlet_tag="dirichlet",
-                #neumann_tag="neumann",
-
-                dirichlet_tag=BTAG_ALL,
-                neumann_tag=BTAG_NONE,
-
-                #dirichlet_tag=BTAG_ALL,
-                #neumann_tag=BTAG_NONE,
-
-                dirichlet_bc=GivenFunction(dirichlet_bc),
-                neumann_bc=ConstantGivenFunction(-10),
-
-                scheme=StabilizedCentralSecondDerivative(),
-                #scheme=LDGSecondDerivative(),
-                #scheme=IPDGSecondDerivative(),
-                )
-        bound_op = op.bind(discr)
-
-        from grudge.iterative import parallel_cg
-        u = -parallel_cg(rcon, -bound_op,
-                bound_op.prepare_rhs(discr.interpolate_volume_function(rhs_c)),
-                debug=20, tol=5e-4,
-                dot=discr.nodewise_dot_product,
-                x=discr.volume_zeros())
-
-        if write_output:
-            from grudge.visualization import SiloVisualizer, VtkVisualizer
-            vis = VtkVisualizer(discr, rcon)
-            visf = vis.make_file("fld")
-            vis.add_data(visf, [ ("sol", discr.convert_volume(u, kind="numpy")), ])
-            visf.close()
-    finally:
-        discr.close()
-
-
-
-
-
-if __name__ == "__main__":
-    main()
-
-
-
-
-# entry points for py.test ----------------------------------------------------
-from pytools.test import mark_test
-@mark_test.long
-def test_poisson():
-    main(write_output=False)
diff --git a/unported-examples/wave/wiggly.py b/unported-examples/wave/wiggly.py
deleted file mode 100644
index db383255fd8a1cb47958d05d33218298202d61eb..0000000000000000000000000000000000000000
--- a/unported-examples/wave/wiggly.py
+++ /dev/null
@@ -1,225 +0,0 @@
-"""Wiggly geometry wave propagation."""
-
-from __future__ import division
-from __future__ import absolute_import
-from __future__ import print_function
-from six.moves import range
-
-__copyright__ = "Copyright (C) 2009 Andreas Kloeckner"
-
-__license__ = """
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-"""
-
-
-import numpy as np
-from grudge.mesh import BTAG_ALL, BTAG_NONE  # noqa
-
-
-def main(write_output=True,
-        flux_type_arg="upwind", dtype=np.float64, debug=[]):
-    from math import sin, cos, pi, exp, sqrt  # noqa
-
-    from grudge.backends import guess_run_context
-    rcon = guess_run_context()
-
-    if rcon.is_head_rank:
-        from grudge.mesh.reader.gmsh import generate_gmsh
-        mesh = generate_gmsh(GEOMETRY, 2,
-                allow_internal_boundaries=True,
-                force_dimension=2)
-
-        print("%d elements" % len(mesh.elements))
-        mesh_data = rcon.distribute_mesh(mesh)
-    else:
-        mesh_data = rcon.receive_mesh()
-
-    discr = rcon.make_discretization(mesh_data, order=4, debug=debug,
-            default_scalar_type=dtype)
-    from grudge.timestep.runge_kutta import LSRK4TimeStepper
-    stepper = LSRK4TimeStepper(dtype=dtype)
-
-    from grudge.visualization import VtkVisualizer
-    if write_output:
-        vis = VtkVisualizer(discr, rcon, "fld")
-
-    source_center = 0
-    source_width = 0.05
-    source_omega = 3
-
-    import grudge.symbolic as sym
-    sym_x = sym.nodes(2)
-    sym_source_center_dist = sym_x - source_center
-
-    from grudge.models.wave import StrongWaveOperator
-    op = StrongWaveOperator(-1, discr.dimensions,
-            source_f=
-            sym.FunctionSymbol("sin")(source_omega*sym.ScalarParameter("t"))
-            * sym.FunctionSymbol("exp")(
-                -np.dot(sym_source_center_dist, sym_source_center_dist)
-                / source_width**2),
-            dirichlet_tag="boundary",
-            neumann_tag=BTAG_NONE,
-            radiation_tag=BTAG_NONE,
-            flux_type=flux_type_arg
-            )
-
-    from grudge.tools import join_fields
-    fields = join_fields(discr.volume_zeros(dtype=dtype),
-            [discr.volume_zeros(dtype=dtype) for i in range(discr.dimensions)])
-
-    # diagnostics setup -------------------------------------------------------
-    from logpyle import LogManager, \
-            add_general_quantities, \
-            add_simulation_quantities, \
-            add_run_info
-
-    if write_output:
-        log_file_name = "wiggly.dat"
-    else:
-        log_file_name = None
-
-    logmgr = LogManager(log_file_name, "w", rcon.communicator)
-    add_run_info(logmgr)
-    add_general_quantities(logmgr)
-    add_simulation_quantities(logmgr)
-    discr.add_instrumentation(logmgr)
-
-    stepper.add_instrumentation(logmgr)
-
-    logmgr.add_watches(["step.max", "t_sim.max", "t_step.max"])
-
-    # timestep loop -----------------------------------------------------------
-    rhs = op.bind(discr)
-    try:
-        from grudge.timestep import times_and_steps
-        step_it = times_and_steps(
-                final_time=4, logmgr=logmgr,
-                max_dt_getter=lambda t: op.estimate_timestep(discr,
-                    stepper=stepper, t=t, fields=fields))
-
-        for step, t, dt in step_it:
-            if step % 10 == 0 and write_output:
-                visf = vis.make_file("fld-%04d" % step)
-
-                vis.add_data(visf,
-                        [
-                            ("u", fields[0]),
-                            ("v", fields[1:]),
-                        ],
-                        time=t,
-                        step=step)
-                visf.close()
-
-            fields = stepper(fields, t, dt, rhs)
-
-        assert discr.norm(fields) < 1
-        assert fields[0].dtype == dtype
-
-    finally:
-        if write_output:
-            vis.close()
-
-        logmgr.close()
-        discr.close()
-
-GEOMETRY = """
-w = 1;
-dx = 0.2;
-ch_width = 0.2;
-rows = 4;
-
-Point(0) = {0,0,0};
-Point(1) = {w,0,0};
-
-bottom_line = newl;
-Line(bottom_line) = {0,1};
-
-left_pts[] = { 0 };
-right_pts[] = { 1 };
-
-left_pts[] = { };
-emb_lines[] = {};
-
-For row In {1:rows}
-  If (row % 2 == 0)
-    // left
-    rp = newp; Point(rp) = {w,dx*row, 0};
-    right_pts[] += {rp};
-
-    mp = newp; Point(mp) = {ch_width,dx*row, 0};
-    emb_line = newl; Line(emb_line) = {mp,rp};
-    emb_lines[] += {emb_line};
-  EndIf
-  If (row % 2)
-    // right
-    lp = newp; Point(lp) = {0,dx*row, 0};
-    left_pts[] += {lp};
-
-    mp = newp; Point(mp) = { w-ch_width,dx*row, 0};
-    emb_line = newl; Line(emb_line) = {mp,lp};
-    emb_lines[] += {emb_line};
-  EndIf
-EndFor
-
-lep = newp; Point(lep) = {0,(rows+1)*dx,0};
-rep = newp; Point(rep) = {w,(rows+1)*dx,0};
-top_line = newl; Line(top_line) = {lep,rep};
-
-left_pts[] += { lep };
-right_pts[] += { rep };
-
-lines[] = {bottom_line};
-
-For i In {0:#right_pts[]-2}
-  l = newl; Line(l) = {right_pts[i], right_pts[i+1]};
-  lines[] += {l};
-EndFor
-
-lines[] += {-top_line};
-
-For i In {#left_pts[]-1:0:-1}
-  l = newl; Line(l) = {left_pts[i], left_pts[i-1]};
-  lines[] += {l};
-EndFor
-
-Line Loop (1) = lines[];
-
-Plane Surface (1) = {1};
-Physical Surface(1) = {1};
-
-For i In {0:#emb_lines[]-1}
-  Line { emb_lines[i] } In Surface { 1 };
-EndFor
-
-boundary_lines[] = {};
-boundary_lines[] += lines[];
-boundary_lines[] += emb_lines[];
-Physical Line ("boundary") = boundary_lines[];
-
-Mesh.CharacteristicLengthFactor = 0.4;
-"""
-
-if __name__ == "__main__":
-    main()
-
-
-
-
-