diff --git a/doc/operators.rst b/doc/operators.rst
index a51d0f2a0e0e70c0989f5ac36c70d22323e0bc40..550a78023e955d47a2a7e13aef747c6b1a0c9fbe 100644
--- a/doc/operators.rst
+++ b/doc/operators.rst
@@ -1,5 +1,16 @@
-Discontinuous Galerkin Operators
+Discontinuous Galerkin operators
 ================================
 
 .. automodule:: grudge.op
 .. automodule:: grudge.trace_pair
+
+
+Transfering data between discretizations
+========================================
+
+.. automodule:: grudge.projection
+
+Reductions
+==========
+
+.. automodule:: grudge.reductions
diff --git a/examples/advection/surface.py b/examples/advection/surface.py
index 9dcdc93ec550acdb0e50d210ce470c897bb04d24..c81e23386c21f4284451f9225a76f9dc5114cf18 100644
--- a/examples/advection/surface.py
+++ b/examples/advection/surface.py
@@ -172,7 +172,7 @@ def main(ctx_factory, dim=2, order=4, use_quad=False, visualize=False):
     # {{{ Surface advection operator
 
     # velocity field
-    x = thaw(op.nodes(dcoll), actx)
+    x = thaw(dcoll.nodes(), actx)
     c = make_obj_array([-x[1], x[0], 0.0])[:dim]
 
     def f_initial_condition(x):
@@ -234,7 +234,7 @@ def main(ctx_factory, dim=2, order=4, use_quad=False, visualize=False):
 
         df = dof_desc.DOFDesc(FACE_RESTR_INTERIOR)
         face_discr = dcoll.discr_from_dd(df)
-        face_normal = thaw(op.normal(dcoll, dd=df), actx)
+        face_normal = thaw(dcoll.normal(dd=df), actx)
 
         from meshmode.discretization.visualization import make_visualizer
         vis = make_visualizer(actx, face_discr)
diff --git a/examples/advection/var-velocity.py b/examples/advection/var-velocity.py
index b050c60b8e9648a6ae51159346ae27eee5dfa50b..fca97d27276f768b781bc844623752aca0104de0 100644
--- a/examples/advection/var-velocity.py
+++ b/examples/advection/var-velocity.py
@@ -169,7 +169,7 @@ def main(ctx_factory, dim=2, order=4, use_quad=False, visualize=False):
 
     from grudge.models.advection import VariableCoefficientAdvectionOperator
 
-    x = thaw(op.nodes(dcoll), actx)
+    x = thaw(dcoll.nodes(), actx)
 
     # velocity field
     if dim == 1:
diff --git a/examples/advection/weak.py b/examples/advection/weak.py
index 9300ab01f4e9a7aa7288bf847429f49c05d5c914..0d0abb6af1ef3f62d984033bd6798aa07da4fa24 100644
--- a/examples/advection/weak.py
+++ b/examples/advection/weak.py
@@ -152,13 +152,13 @@ def main(ctx_factory, dim=2, order=4, visualize=False):
         dcoll,
         c,
         inflow_u=lambda t: u_analytic(
-            thaw(op.nodes(dcoll, dd=BTAG_ALL), actx),
+            thaw(dcoll.nodes(dd=BTAG_ALL), actx),
             t=t
         ),
         flux_type=flux_type
     )
 
-    nodes = thaw(op.nodes(dcoll), actx)
+    nodes = thaw(dcoll.nodes(), actx)
     u = u_analytic(nodes, t=0)
 
     def rhs(t, u):
diff --git a/examples/geometry.py b/examples/geometry.py
index faf0fc42c1553ea764dbc1621142e21c9f3f4599..a40a000a63333be7771fc6d3fd1cd3ad76c4a957 100644
--- a/examples/geometry.py
+++ b/examples/geometry.py
@@ -32,8 +32,6 @@ import pyopencl.tools as cl_tools
 
 from arraycontext import PyOpenCLArrayContext, thaw
 
-import grudge.op as op
-
 from grudge import DiscretizationCollection, shortcuts
 
 
@@ -51,9 +49,9 @@ def main(write_output=True):
 
     dcoll = DiscretizationCollection(actx, mesh, order=4)
 
-    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)
+    nodes = thaw(dcoll.nodes(), actx)
+    bdry_nodes = thaw(dcoll.nodes(dd=BTAG_ALL), actx)
+    bdry_normals = thaw(dcoll.normal(dd=BTAG_ALL), actx)
 
     if write_output:
         vis = shortcuts.make_visualizer(dcoll)
diff --git a/examples/maxwell/cavities.py b/examples/maxwell/cavities.py
index 3d68e513fdb51b5b66801d01e90bb2dfa39860e6..5791a3d8e98866ad0cd3bae7c6ca11f7efc6a09d 100644
--- a/examples/maxwell/cavities.py
+++ b/examples/maxwell/cavities.py
@@ -84,7 +84,7 @@ def main(ctx_factory, dim=3, order=4, visualize=False):
         else:
             return get_rectangular_cavity_mode(actx, x, t, 1, (2, 3))
 
-    fields = cavity_mode(thaw(op.nodes(dcoll), actx), t=0)
+    fields = cavity_mode(thaw(dcoll.nodes(), actx), t=0)
 
     maxwell_operator.check_bc_coverage(mesh)
 
diff --git a/examples/wave/var-propagation-speed.py b/examples/wave/var-propagation-speed.py
index 0b8d51b9bdfa0388612366c1d95cedcc931eedb2..04605b7e6d6ff92f8607be09fcf3ac58fa755acd 100644
--- a/examples/wave/var-propagation-speed.py
+++ b/examples/wave/var-propagation-speed.py
@@ -63,7 +63,7 @@ def main(ctx_factory, dim=2, order=4, visualize=False):
         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)
+        nodes = thaw(dcoll.nodes(), actx)
         source_center_dist = flat_obj_array(
             [nodes[i] - source_center[i] for i in range(dcoll.dim)]
         )
@@ -75,7 +75,7 @@ def main(ctx_factory, dim=2, order=4, visualize=False):
             )
         )
 
-    x = thaw(op.nodes(dcoll), actx)
+    x = thaw(dcoll.nodes(), actx)
     ones = dcoll.zeros(actx) + 1
     c = actx.np.where(np.dot(x, x) < 0.15, 0.1 * ones, 0.2 * ones)
 
diff --git a/examples/wave/wave-min-mpi.py b/examples/wave/wave-min-mpi.py
index a3e75c7b525628bcaa6bd861a1009ec9cd79d985..c803a897a8eb32acc7aa40c6f7eef176d5d148d1 100644
--- a/examples/wave/wave-min-mpi.py
+++ b/examples/wave/wave-min-mpi.py
@@ -84,7 +84,7 @@ def main(ctx_factory, dim=2, order=4, visualize=False):
         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)
+        nodes = thaw(dcoll.nodes(), actx)
         source_center_dist = flat_obj_array(
             [nodes[i] - source_center[i] for i in range(dcoll.dim)]
         )
diff --git a/examples/wave/wave-min.py b/examples/wave/wave-min.py
index 7b18d3dfaec68ab38af936c45f395bd10ad29a75..ab983681f41ba9b043aa7f18e4365d70cd0a93a2 100644
--- a/examples/wave/wave-min.py
+++ b/examples/wave/wave-min.py
@@ -65,7 +65,7 @@ def main(ctx_factory, dim=2, order=4, visualize=False):
         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)
+        nodes = thaw(dcoll.nodes(), actx)
         source_center_dist = flat_obj_array(
             [nodes[i] - source_center[i] for i in range(dcoll.dim)]
         )
diff --git a/examples/wave/wave-op-mpi.py b/examples/wave/wave-op-mpi.py
index 3fb49d7d1b65eb8d9caa5ac6a5d68c79625c5a94..cc8578c9983ccd24c57278f9e40dd658b08ef307 100644
--- a/examples/wave/wave-op-mpi.py
+++ b/examples/wave/wave-op-mpi.py
@@ -54,7 +54,7 @@ def wave_flux(dcoll, c, w_tpair):
     u = w_tpair[0]
     v = w_tpair[1:]
 
-    normal = thaw(op.normal(dcoll, w_tpair.dd), u.int.array_context)
+    normal = thaw(dcoll.normal(w_tpair.dd), u.int.array_context)
 
     flux_weak = flat_obj_array(
             np.dot(v.avg, normal),
@@ -136,7 +136,7 @@ def bump(actx, dcoll, t=0):
     source_width = 0.05
     source_omega = 3
 
-    nodes = thaw(op.nodes(dcoll), actx)
+    nodes = thaw(dcoll.nodes(), actx)
     center_dist = flat_obj_array([
         nodes[i] - source_center[i]
         for i in range(dcoll.dim)
diff --git a/examples/wave/wave-op-var-velocity.py b/examples/wave/wave-op-var-velocity.py
index e48c8d7141285b40d0a0935c032449e7bbe5a2a4..07b40b8d6a5422a8d4c94962e0441308ee456953 100644
--- a/examples/wave/wave-op-var-velocity.py
+++ b/examples/wave/wave-op-var-velocity.py
@@ -56,7 +56,7 @@ def wave_flux(dcoll, c, w_tpair):
     u = w_tpair[0]
     v = w_tpair[1:]
 
-    normal = thaw(op.normal(dcoll, dd), u.int.array_context)
+    normal = thaw(dcoll.normal(dd), u.int.array_context)
 
     flux_weak = flat_obj_array(
             np.dot(v.avg, normal),
@@ -146,7 +146,7 @@ def bump(actx, dcoll, t=0, width=0.05, center=None):
     center = center[:dcoll.dim]
     source_omega = 3
 
-    nodes = thaw(op.nodes(dcoll), actx)
+    nodes = thaw(dcoll.nodes(), actx)
     center_dist = flat_obj_array([
         nodes[i] - center[i]
         for i in range(dcoll.dim)
diff --git a/examples/wave/wave-op.py b/examples/wave/wave-op.py
index c0494d9f0196da5cb7d951a6950f82250a2e9aa6..56a901716efd60f30f813aacc29539f2b40bf2d2 100644
--- a/examples/wave/wave-op.py
+++ b/examples/wave/wave-op.py
@@ -52,7 +52,7 @@ def wave_flux(dcoll, c, w_tpair):
     u = w_tpair[0]
     v = w_tpair[1:]
 
-    normal = thaw(op.normal(dcoll, w_tpair.dd), u.int.array_context)
+    normal = thaw(dcoll.normal(w_tpair.dd), u.int.array_context)
 
     flux_weak = flat_obj_array(
             np.dot(v.avg, normal),
@@ -127,7 +127,7 @@ def bump(actx, dcoll, t=0):
     source_width = 0.05
     source_omega = 3
 
-    nodes = thaw(op.nodes(dcoll), actx)
+    nodes = thaw(dcoll.nodes(), actx)
     center_dist = flat_obj_array([
         nodes[i] - source_center[i]
         for i in range(dcoll.dim)
diff --git a/grudge/dt_utils.py b/grudge/dt_utils.py
index 14c955595fc5ca41ddcd707066e0238865b273e5..bed073cf100a917fd130950d36641544c1d01024 100644
--- a/grudge/dt_utils.py
+++ b/grudge/dt_utils.py
@@ -2,6 +2,8 @@
 
 .. autofunction:: dt_non_geometric_factor
 .. autofunction:: dt_geometric_factors
+.. autofunction:: h_max_from_volume
+.. autofunction:: h_min_from_volume
 """
 
 __copyright__ = """
@@ -33,7 +35,7 @@ import numpy as np
 
 from arraycontext import FirstAxisIsElementsTag
 
-from grudge.dof_desc import DD_VOLUME, DOFDesc
+from grudge.dof_desc import DD_VOLUME, DOFDesc, as_dofdesc
 from grudge.discretization import DiscretizationCollection
 
 import grudge.op as op
@@ -90,6 +92,70 @@ def dt_non_geometric_factor(dcoll: DiscretizationCollection, dd=None) -> float:
     return min(min_delta_rs)
 
 
+# {{{ Mesh size functions
+
+@memoize_on_first_arg
+def h_max_from_volume(
+        dcoll: DiscretizationCollection, dim=None, dd=None) -> float:
+    """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.
+    """
+    from grudge.reductions import nodal_max, elementwise_sum
+
+    if dd is None:
+        dd = DD_VOLUME
+    dd = 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, op.mass(dcoll, dd, ones))
+    ) ** (1.0 / dim)
+
+
+@memoize_on_first_arg
+def h_min_from_volume(
+        dcoll: DiscretizationCollection, dim=None, dd=None) -> float:
+    """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.
+    """
+    from grudge.reductions import nodal_min, elementwise_sum
+
+    if dd is None:
+        dd = DD_VOLUME
+    dd = 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, op.mass(dcoll, dd, ones))
+    ) ** (1.0 / dim)
+
+
 @memoize_on_first_arg
 def dt_geometric_factors(
         dcoll: DiscretizationCollection, dd=None) -> DOFArray:
@@ -175,3 +241,8 @@ def dt_geometric_factors(
             for cv_i, sae_i in zip(cell_vols, surface_areas)
         )
     )
+
+# }}}
+
+
+# vim: foldmethod=marker
diff --git a/grudge/interpolation.py b/grudge/interpolation.py
new file mode 100644
index 0000000000000000000000000000000000000000..61bdf1a133b984a6c26ebb26992e15368099b611
--- /dev/null
+++ b/grudge/interpolation.py
@@ -0,0 +1,48 @@
+"""
+.. currentmodule:: grudge.op
+
+Interpolation
+-------------
+
+.. autofunction:: interp
+"""
+
+__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.discretization import DiscretizationCollection
+
+
+# FIXME: Should revamp 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("'interp' currently calls to 'project'",
+         UserWarning, stacklevel=2)
+
+    from grudge.projection import project
+
+    return project(dcoll, src, tgt, vec)
diff --git a/grudge/models/advection.py b/grudge/models/advection.py
index 2469498aef61fff823d794123992f586cfa1cb39..0ba4acb3515a543e4cda9431167c55d546d00500 100644
--- a/grudge/models/advection.py
+++ b/grudge/models/advection.py
@@ -43,7 +43,7 @@ def advection_weak_flux(dcoll, flux_type, u_tpair, velocity):
     """
     actx = u_tpair.int.array_context
     dd = u_tpair.dd
-    normal = thaw(op.normal(dcoll, dd), actx)
+    normal = thaw(dcoll.normal(dd), actx)
     v_dot_n = np.dot(velocity, normal)
 
     flux_type = flux_type.lower()
@@ -92,7 +92,7 @@ class StrongAdvectionOperator(AdvectionOperatorBase):
     def flux(self, u_tpair):
         actx = u_tpair.int.array_context
         dd = u_tpair.dd
-        normal = thaw(op.normal(self.dcoll, dd), actx)
+        normal = thaw(self.dcoll.normal(dd), actx)
         v_dot_normal = np.dot(self.v, normal)
 
         return u_tpair.int * v_dot_normal - self.weak_flux(u_tpair)
@@ -285,7 +285,7 @@ def v_dot_n_tpair(actx, dcoll, velocity, trace_dd):
     from grudge.trace_pair import TracePair
     from meshmode.discretization.connection import FACE_RESTR_INTERIOR
 
-    normal = thaw(op.normal(dcoll, trace_dd.with_discr_tag(None)), actx)
+    normal = thaw(dcoll.normal(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)
 
diff --git a/grudge/models/em.py b/grudge/models/em.py
index 38b73d3d2a598acef851586cd74c982761efc102..8960e0d3dbb9c3902f962848b5f0b960a60530b6 100644
--- a/grudge/models/em.py
+++ b/grudge/models/em.py
@@ -120,7 +120,7 @@ class MaxwellOperator(HyperbolicOperator):
         As per Hesthaven and Warburton page 433.
         """
 
-        normal = thaw(op.normal(self.dcoll, wtpair.dd), self.dcoll._setup_actx)
+        normal = thaw(self.dcoll.normal(wtpair.dd), self.dcoll._setup_actx)
 
         if self.fixed_material:
             e, h = self.split_eh(wtpair)
@@ -220,7 +220,7 @@ class MaxwellOperator(HyperbolicOperator):
         absorbing boundary conditions.
         """
 
-        absorb_normal = thaw(op.normal(self.dcoll, dd=self.absorb_tag),
+        absorb_normal = thaw(self.dcoll.normal(dd=self.absorb_tag),
                              self.dcoll._setup_actx)
 
         e, h = self.split_eh(w)
diff --git a/grudge/models/wave.py b/grudge/models/wave.py
index e101fd0443918c3cbb3b5f530a002ff2437578fd..fa42879670f24e5393b8fc0a4002bdf2d5f9227c 100644
--- a/grudge/models/wave.py
+++ b/grudge/models/wave.py
@@ -90,7 +90,7 @@ class WeakWaveOperator(HyperbolicOperator):
         u = wtpair[0]
         v = wtpair[1:]
         actx = u.int.array_context
-        normal = thaw(op.normal(self.dcoll, wtpair.dd), actx)
+        normal = thaw(self.dcoll.normal(wtpair.dd), actx)
 
         central_flux_weak = -self.c*flat_obj_array(
                 np.dot(v.avg, normal),
@@ -133,7 +133,7 @@ class WeakWaveOperator(HyperbolicOperator):
         neu_bc = flat_obj_array(neu_u, -neu_v)
 
         # radiation BCs -------------------------------------------------------
-        rad_normal = thaw(op.normal(dcoll, dd=self.radiation_tag), actx)
+        rad_normal = thaw(dcoll.normal(dd=self.radiation_tag), actx)
 
         rad_u = op.project(dcoll, "vol", self.radiation_tag, u)
         rad_v = op.project(dcoll, "vol", self.radiation_tag, v)
@@ -233,7 +233,7 @@ class VariableCoefficientWeakWaveOperator(HyperbolicOperator):
         u = wtpair[1]
         v = wtpair[2:]
         actx = u.int.array_context
-        normal = thaw(op.normal(self.dcoll, wtpair.dd), actx)
+        normal = thaw(self.dcoll.normal(wtpair.dd), actx)
 
         flux_central_weak = -0.5 * flat_obj_array(
             np.dot(v.int*c.int + v.ext*c.ext, normal),
@@ -282,7 +282,7 @@ class VariableCoefficientWeakWaveOperator(HyperbolicOperator):
         neu_bc = flat_obj_array(neu_c, neu_u, -neu_v)
 
         # radiation BCs -------------------------------------------------------
-        rad_normal = thaw(op.normal(dcoll, dd=self.radiation_tag), actx)
+        rad_normal = thaw(dcoll.normal(dd=self.radiation_tag), actx)
 
         rad_c = op.project(dcoll, "vol", self.radiation_tag, self.c)
         rad_u = op.project(dcoll, "vol", self.radiation_tag, u)
diff --git a/grudge/op.py b/grudge/op.py
index 200c89324a6ba82d2c33f55e0c7bd79a80b48368..1ab764e08dfc28dd0a3e4a07330fe1fba5bf6200 100644
--- a/grudge/op.py
+++ b/grudge/op.py
@@ -1,20 +1,4 @@
 """
-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
 ^^^^^^^^^^^^^^^^
 
@@ -38,24 +22,6 @@ 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:: elementwise_sum
-.. autofunction:: elementwise_integral
 """
 
 __copyright__ = """
@@ -84,9 +50,6 @@ THE SOFTWARE.
 """
 
 
-from numbers import Number
-from functools import reduce
-
 from arraycontext import (
     ArrayContext,
     FirstAxisIsElementsTag,
@@ -95,19 +58,29 @@ from arraycontext import (
 
 from grudge.discretization import DiscretizationCollection
 
-from pytools import (
-    memoize_in,
-    memoize_on_first_arg,
-    keyed_memoize_in
-)
+from pytools import memoize_in, keyed_memoize_in
 from pytools.obj_array import obj_array_vectorize, make_obj_array
 
 from meshmode.dof_array import DOFArray
 
 import numpy as np
+
 import grudge.dof_desc as dof_desc
 
-from grudge.trace_pair import (  # noqa
+from grudge.interpolation import interp  # noqa: F401
+from grudge.projection import project  # noqa: F401
+
+from grudge.reductions import (  # noqa: F401
+    norm,
+    nodal_sum,
+    nodal_min,
+    nodal_max,
+    integral,
+    elementwise_sum,
+    elementwise_integral,
+)
+
+from grudge.trace_pair import (  # noqa: F401
     interior_trace_pair,
     interior_trace_pairs,
     connected_ranks,
@@ -130,129 +103,6 @@ class HasElementwiseMatvecTag(FirstAxisIsElementsTag):
 # }}}
 
 
-# {{{ 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)
-#
-#     return 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` or a
-        :class:`~arraycontext.ArrayContainer`.
-    """
-    src = dof_desc.as_dofdesc(src)
-    tgt = dof_desc.as_dofdesc(tgt)
-    if src == tgt:
-        return vec
-
-    if isinstance(vec, np.ndarray):
-        return obj_array_vectorize(
-                lambda el: project(dcoll, src, tgt, el), vec)
-
-    if isinstance(vec, Number):
-        return vec
-
-    return dcoll.connection_from_dds(src, tgt)(vec)
-
-# }}}
-
-
-# {{{ geometric properties
-
-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.
-    """
-    if dd is None:
-        dd = dof_desc.DD_VOLUME
-    dd = dof_desc.as_dofdesc(dd)
-
-    return dcoll.nodes(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`\ s.
-    """
-    return dcoll.normal(dd)
-
-
-@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 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):
@@ -1019,210 +869,4 @@ def face_mass(dcoll: DiscretizationCollection, *args):
 # }}}
 
 
-# {{{ Nodal reductions
-
-def _norm(dcoll: DiscretizationCollection, vec, p, dd):
-    if isinstance(vec, Number):
-        return np.fabs(vec)
-    if p == 2:
-        return np.real_if_close(np.sqrt(
-            nodal_sum(
-                dcoll,
-                dd,
-                vec.conj() * _apply_mass_operator(dcoll, dd, dd, vec)
-            )
-        ))
-    elif p == np.inf:
-        return nodal_max(dcoll, dd, abs(vec))
-    else:
-        raise NotImplementedError("Unsupported value of p")
-
-
-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: We should make the nodal reductions respect MPI.
-    # See: https://github.com/inducer/grudge/issues/112 and
-    # https://github.com/inducer/grudge/issues/113
-
-    if dd is None:
-        dd = dof_desc.DD_VOLUME
-
-    dd = dof_desc.as_dofdesc(dd)
-
-    if isinstance(vec, np.ndarray):
-        if p == 2:
-            return sum(
-                    norm(dcoll, vec[idx], p, dd=dd)**2
-                    for idx in np.ndindex(vec.shape))**0.5
-        elif p == np.inf:
-            return max(
-                    norm(dcoll, vec[idx], np.inf, dd=dd)
-                    for idx in np.ndindex(vec.shape))
-        else:
-            raise ValueError("unsupported norm order")
-
-    return _norm(dcoll, vec, p, dd)
-
-
-def nodal_sum(dcoll: DiscretizationCollection, dd, vec):
-    r"""Return the nodal sum 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 sum.
-    """
-    # FIXME: We should make the nodal reductions respect MPI.
-    # See: https://github.com/inducer/grudge/issues/112 and
-    # https://github.com/inducer/grudge/issues/113
-    actx = vec.array_context
-    return sum([actx.np.sum(grp_ary) for grp_ary in 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: We should make the nodal reductions respect MPI.
-    # See: https://github.com/inducer/grudge/issues/112 and
-    # https://github.com/inducer/grudge/issues/113
-    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: DiscretizationCollection, dd, vec):
-    r"""Return the nodal maximum 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 maximum.
-    """
-    # FIXME: We should make the nodal reductions respect MPI.
-    # See: https://github.com/inducer/grudge/issues/112 and
-    # https://github.com/inducer/grudge/issues/113
-    actx = vec.array_context
-    return reduce(lambda acc, grp_ary: actx.np.maximum(acc, actx.np.max(grp_ary)),
-                  vec, -np.inf)
-
-
-def integral(dcoll: DiscretizationCollection, dd, vec):
-    """Numerically integrates a function represented by a
-    :class:`~meshmode.dof_array.DOFArray` of degrees of freedom.
-
-    :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.
-    """
-
-    dd = dof_desc.as_dofdesc(dd)
-
-    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)
-    )
-
-# }}}
-
-
-# {{{  Elementwise reductions
-
-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()
-
-
-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.
-
-    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*.
-    """
-
-    if len(args) == 1:
-        vec, = args
-        dd = dof_desc.DOFDesc("vol", dof_desc.DISCR_TAG_BASE)
-    elif len(args) == 2:
-        dd, vec = args
-    else:
-        raise TypeError("invalid number of arguments")
-
-    dd = dof_desc.as_dofdesc(dd)
-
-    if isinstance(vec, np.ndarray):
-        return obj_array_vectorize(
-            lambda vi: elementwise_sum(dcoll, dd, vi), vec
-        )
-
-    actx = vec.array_context
-
-    return DOFArray(
-        actx,
-        data=tuple(
-            actx.call_loopy(
-                _map_elementwise_reduction(actx, "sum"),
-                operand=vec_i
-            )["result"]
-            for vec_i in vec
-        )
-    )
-
-
-def elementwise_integral(dcoll: DiscretizationCollection, dd, vec):
-    """Numerically integrates a function represented by a
-    :class:`~meshmode.dof_array.DOFArray` of degrees of freedom in
-    each element of a discretization, given by *dd*.
-
-    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
-    :arg vec: a :class:`~meshmode.dof_array.DOFArray`
-    :returns: a :class:`~meshmode.dof_array.DOFArray` containing the
-        elementwise integral of *vec*, represented as a constant function on each
-        cell.
-    """
-
-    dd = dof_desc.as_dofdesc(dd)
-
-    ones = dcoll.discr_from_dd(dd).zeros(vec.array_context) + 1.0
-    return elementwise_sum(
-        dcoll, dd, vec * _apply_mass_operator(dcoll, dd, dd, ones)
-    )
-
-# }}}
-
-
 # vim: foldmethod=marker
diff --git a/grudge/projection.py b/grudge/projection.py
new file mode 100644
index 0000000000000000000000000000000000000000..3129253ca0b9c355334a452da4a60627dd5ea2cc
--- /dev/null
+++ b/grudge/projection.py
@@ -0,0 +1,68 @@
+"""
+.. currentmodule:: grudge.op
+
+Projections
+-----------
+
+.. autofunction:: project
+"""
+
+__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.discretization import DiscretizationCollection
+from grudge.dof_desc import as_dofdesc
+
+from numbers import Number
+
+from pytools.obj_array import obj_array_vectorize
+
+
+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` or a
+        :class:`~arraycontext.ArrayContainer`.
+    """
+    src = as_dofdesc(src)
+    tgt = as_dofdesc(tgt)
+
+    if src == tgt:
+        return vec
+
+    if isinstance(vec, np.ndarray):
+        return obj_array_vectorize(
+                lambda el: project(dcoll, src, tgt, el), vec)
+
+    if isinstance(vec, Number):
+        return vec
+
+    return dcoll.connection_from_dds(src, tgt)(vec)
diff --git a/grudge/reductions.py b/grudge/reductions.py
new file mode 100644
index 0000000000000000000000000000000000000000..b8a8a868bf2549041205bb4b603d4033ab38f9f4
--- /dev/null
+++ b/grudge/reductions.py
@@ -0,0 +1,272 @@
+"""
+.. currentmodule:: grudge.op
+
+Nodal reductions
+----------------
+
+.. autofunction:: norm
+.. autofunction:: nodal_sum
+.. autofunction:: nodal_min
+.. autofunction:: nodal_max
+.. autofunction:: integral
+
+Elementwise reductions
+----------------------
+
+.. autofunction:: elementwise_sum
+.. autofunction:: elementwise_integral
+"""
+
+__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 numbers import Number
+from functools import reduce
+
+from arraycontext import (
+    ArrayContext,
+    make_loopy_program
+)
+
+from grudge.discretization import DiscretizationCollection
+
+from pytools import memoize_in
+from pytools.obj_array import obj_array_vectorize
+
+from meshmode.dof_array import DOFArray
+
+import numpy as np
+import grudge.dof_desc as dof_desc
+
+
+# {{{ Nodal reductions
+
+def _norm(dcoll: DiscretizationCollection, vec, p, dd):
+    if isinstance(vec, Number):
+        return np.fabs(vec)
+    if p == 2:
+        from grudge.op import _apply_mass_operator
+        return np.real_if_close(np.sqrt(
+            nodal_sum(
+                dcoll,
+                dd,
+                vec.conj() * _apply_mass_operator(dcoll, dd, dd, vec)
+            )
+        ))
+    elif p == np.inf:
+        return nodal_max(dcoll, dd, abs(vec))
+    else:
+        raise NotImplementedError("Unsupported value of p")
+
+
+def norm(dcoll: DiscretizationCollection, vec, p, dd=None) -> float:
+    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: We should make the nodal reductions respect MPI.
+    # See: https://github.com/inducer/grudge/issues/112 and
+    # https://github.com/inducer/grudge/issues/113
+
+    if dd is None:
+        dd = dof_desc.DD_VOLUME
+
+    dd = dof_desc.as_dofdesc(dd)
+
+    if isinstance(vec, np.ndarray):
+        if p == 2:
+            return sum(
+                    norm(dcoll, vec[idx], p, dd=dd)**2
+                    for idx in np.ndindex(vec.shape))**0.5
+        elif p == np.inf:
+            return max(
+                    norm(dcoll, vec[idx], np.inf, dd=dd)
+                    for idx in np.ndindex(vec.shape))
+        else:
+            raise ValueError("unsupported norm order")
+
+    return _norm(dcoll, vec, p, dd)
+
+
+def nodal_sum(dcoll: DiscretizationCollection, dd, vec) -> float:
+    r"""Return the nodal sum 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 sum.
+    """
+    # FIXME: We should make the nodal reductions respect MPI.
+    # See: https://github.com/inducer/grudge/issues/112 and
+    # https://github.com/inducer/grudge/issues/113
+    actx = vec.array_context
+    return sum([actx.np.sum(grp_ary) for grp_ary in vec])
+
+
+def nodal_min(dcoll: DiscretizationCollection, dd, vec) -> float:
+    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: We should make the nodal reductions respect MPI.
+    # See: https://github.com/inducer/grudge/issues/112 and
+    # https://github.com/inducer/grudge/issues/113
+    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: DiscretizationCollection, dd, vec) -> float:
+    r"""Return the nodal maximum 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 maximum.
+    """
+    # FIXME: We should make the nodal reductions respect MPI.
+    # See: https://github.com/inducer/grudge/issues/112 and
+    # https://github.com/inducer/grudge/issues/113
+    actx = vec.array_context
+    return reduce(lambda acc, grp_ary: actx.np.maximum(acc, actx.np.max(grp_ary)),
+                  vec, -np.inf)
+
+
+def integral(dcoll: DiscretizationCollection, dd, vec) -> float:
+    """Numerically integrates a function represented by a
+    :class:`~meshmode.dof_array.DOFArray` of degrees of freedom.
+
+    :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.
+    """
+    from grudge.op import _apply_mass_operator
+
+    dd = dof_desc.as_dofdesc(dd)
+
+    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)
+    )
+
+# }}}
+
+
+# {{{  Elementwise reductions
+
+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()
+
+
+def elementwise_sum(dcoll: DiscretizationCollection, *args) -> DOFArray:
+    r"""Returns a vector of DOFs with all entries on each element set
+    to the sum of DOFs on that element.
+
+    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*.
+    """
+
+    if len(args) == 1:
+        vec, = args
+        dd = dof_desc.DOFDesc("vol", dof_desc.DISCR_TAG_BASE)
+    elif len(args) == 2:
+        dd, vec = args
+    else:
+        raise TypeError("invalid number of arguments")
+
+    dd = dof_desc.as_dofdesc(dd)
+
+    if isinstance(vec, np.ndarray):
+        return obj_array_vectorize(
+            lambda vi: elementwise_sum(dcoll, dd, vi), vec
+        )
+
+    actx = vec.array_context
+
+    return DOFArray(
+        actx,
+        data=tuple(
+            actx.call_loopy(
+                _map_elementwise_reduction(actx, "sum"),
+                operand=vec_i
+            )["result"]
+            for vec_i in vec
+        )
+    )
+
+
+def elementwise_integral(dcoll: DiscretizationCollection, dd, vec) -> DOFArray:
+    """Numerically integrates a function represented by a
+    :class:`~meshmode.dof_array.DOFArray` of degrees of freedom in
+    each element of a discretization, given by *dd*.
+
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+    :arg vec: a :class:`~meshmode.dof_array.DOFArray`
+    :returns: a :class:`~meshmode.dof_array.DOFArray` containing the
+        elementwise integral if *vec*.
+    """
+    from grudge.op import _apply_mass_operator
+
+    dd = dof_desc.as_dofdesc(dd)
+
+    ones = dcoll.discr_from_dd(dd).zeros(vec.array_context) + 1.0
+    return elementwise_sum(
+        dcoll, dd, vec * _apply_mass_operator(dcoll, dd, dd, ones)
+    )
+
+# }}}
+
+
+# vim: foldmethod=marker
diff --git a/grudge/trace_pair.py b/grudge/trace_pair.py
index ad2e9c815f6b38d48f7dabf95314bdfceda553fc..a0667d056d3023baeecdec6a3240095489830eb4 100644
--- a/grudge/trace_pair.py
+++ b/grudge/trace_pair.py
@@ -1,3 +1,25 @@
+"""
+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
+"""
+
 __copyright__ = """
 Copyright (C) 2021 University of Illinois Board of Trustees
 """
@@ -45,29 +67,6 @@ 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(
diff --git a/test/test_grudge.py b/test/test_grudge.py
index 4963642ecf46cb300f719686f87b7e63049afd73..522028fe3f0d449e5f112e4dd29ef9c302e8351b 100644
--- a/test/test_grudge.py
+++ b/test/test_grudge.py
@@ -250,7 +250,9 @@ def test_mass_surface_area(actx_factory, name):
         # }}}
 
         # compute max element size
-        h_max = op.h_max_from_volume(dcoll)
+        from grudge.dt_utils import h_max_from_volume
+
+        h_max = h_max_from_volume(dcoll)
 
         eoc.add_data_point(h_max, area_error)
 
@@ -321,7 +323,9 @@ def test_mass_operator_inverse(actx_factory, name):
         # }}}
 
         # compute max element size
-        h_max = op.h_max_from_volume(dcoll)
+        from grudge.dt_utils import h_max_from_volume
+
+        h_max = h_max_from_volume(dcoll)
 
         eoc.add_data_point(h_max, inv_error)
 
@@ -378,7 +382,7 @@ def test_face_normal_surface(actx_factory, mesh_name):
     )
     surf_normal = surf_normal / actx.np.sqrt(sum(surf_normal**2))
 
-    face_normal_i = thaw(op.normal(dcoll, df), actx)
+    face_normal_i = thaw(dcoll.normal(df), actx)
     face_normal_e = dcoll.opposite_face_connection()(face_normal_i)
 
     if mesh.ambient_dim == 3:
@@ -498,7 +502,7 @@ def test_2d_gauss_theorem(actx_factory):
     int_1 = op.integral(dcoll, "vol", op.local_div(dcoll, f_volm))
 
     prj_f = op.project(dcoll, "vol", BTAG_ALL, f_volm)
-    normal = thaw(op.normal(dcoll, BTAG_ALL), actx)
+    normal = thaw(dcoll.normal(BTAG_ALL), actx)
     int_2 = op.integral(dcoll, BTAG_ALL, prj_f.dot(normal))
 
     assert abs(int_1 - int_2) < 1e-13
@@ -600,14 +604,14 @@ def test_surface_divergence_theorem(actx_factory, mesh_name, visualize=False):
         ambient_dim = dcoll.ambient_dim
 
         # variables
-        f_num = f(thaw(op.nodes(dcoll, dd=dd), actx))
-        f_quad_num = f(thaw(op.nodes(dcoll, dd=dq), actx))
+        f_num = f(thaw(dcoll.nodes(dd=dd), actx))
+        f_quad_num = f(thaw(dcoll.nodes(dd=dq), actx))
 
         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_normal = thaw(dcoll.normal(df), actx)
         face_f = op.project(dcoll, dd, df, f_num)
 
         # operators
@@ -627,7 +631,9 @@ def test_surface_divergence_theorem(actx_factory, mesh_name, visualize=False):
         logger.info("errors: global %.5e local %.5e", err_global, err_local)
 
         # compute max element size
-        h_max = op.h_max_from_volume(dcoll)
+        from grudge.dt_utils import h_max_from_volume
+
+        h_max = h_max_from_volume(dcoll)
 
         eoc_global.add_data_point(h_max, err_global)
         eoc_local.add_data_point(h_max, err_local)
@@ -747,12 +753,12 @@ def test_convergence_advec(actx_factory, mesh_name, mesh_pars, op_type, flux_typ
                     "weak": WeakAdvectionOperator}[op_type]
         adv_operator = op_class(dcoll, v,
                                 inflow_u=lambda t: u_analytic(
-                                    thaw(op.nodes(dcoll, dd=BTAG_ALL), actx),
+                                    thaw(dcoll.nodes(dd=BTAG_ALL), actx),
                                     t=t
                                 ),
                                 flux_type=flux_type)
 
-        nodes = thaw(op.nodes(dcoll), actx)
+        nodes = thaw(dcoll.nodes(), actx)
         u = u_analytic(nodes, t=0)
 
         def rhs(t, u):
@@ -763,7 +769,9 @@ def test_convergence_advec(actx_factory, mesh_name, mesh_pars, op_type, flux_typ
         else:
             final_time = 0.2
 
-        h_max = op.h_max_from_volume(dcoll, dim=dcoll.ambient_dim)
+        from grudge.dt_utils import h_max_from_volume
+
+        h_max = 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
@@ -842,7 +850,7 @@ def test_convergence_maxwell(actx_factory,  order):
         def analytic_sol(x, t=0):
             return get_rectangular_cavity_mode(actx, x, t, 1, (1, 2, 2))
 
-        nodes = thaw(op.nodes(dcoll), actx)
+        nodes = thaw(dcoll.nodes(), actx)
         fields = analytic_sol(nodes, t=0)
 
         from grudge.models.em import MaxwellOperator
@@ -939,7 +947,7 @@ def test_improvement_quadrature(actx_factory, order):
                 discr_tag_to_group_factory=discr_tag_to_group_factory
             )
 
-            nodes = thaw(op.nodes(dcoll), actx)
+            nodes = thaw(dcoll.nodes(), actx)
 
             def zero_inflow(dtag, t=0):
                 dd = dof_desc.DOFDesc(dtag, qtag)
@@ -988,7 +996,7 @@ def test_bessel(actx_factory):
 
     dcoll = DiscretizationCollection(actx, mesh, order=3)
 
-    nodes = thaw(op.nodes(dcoll), actx)
+    nodes = thaw(dcoll.nodes(), actx)
     r = actx.np.sqrt(nodes[0]**2 + nodes[1]**2)
 
     # FIXME: Bessel functions need to brought out of the symbolic
@@ -1079,50 +1087,13 @@ def test_empty_boundary(actx_factory):
             a=(-0.5,)*dim, b=(0.5,)*dim,
             nelements_per_axis=(8,)*dim, order=4)
     dcoll = DiscretizationCollection(actx, mesh, order=4)
-    normal = op.normal(dcoll, BTAG_NONE)
+    normal = dcoll.normal(BTAG_NONE)
     from meshmode.dof_array import DOFArray
     for component in normal:
         assert isinstance(component, DOFArray)
         assert len(component) == len(dcoll.discr_from_dd(BTAG_NONE).groups)
 
 
-# {{{ DiscretizationCollection testing
-
-def test_dcoll_nodes_and_normals(actx_factory):
-    actx = actx_factory()
-
-    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)
-
-    dcoll = DiscretizationCollection(actx, mesh, order=3)
-
-    # Nodes should be *indentical*
-    nodes = thaw(op.nodes(dcoll), actx)
-    dcoll_nodes = thaw(dcoll.nodes(), actx)
-
-    assert op.norm(dcoll, nodes - dcoll_nodes, np.inf) == 0.0
-
-    nodes_btag = thaw(op.nodes(dcoll, BTAG_ALL), actx)
-    dcoll_nodes_btag = thaw(dcoll.nodes(BTAG_ALL), actx)
-
-    assert op.norm(dcoll, nodes_btag - dcoll_nodes_btag, np.inf) == 0.0
-
-    # Normals should be *indentical*
-    normals = thaw(op.normal(dcoll, "int_faces"), actx)
-    dcoll_normals = thaw(dcoll.normal("int_faces"), actx)
-
-    assert op.norm(dcoll, normals - dcoll_normals, np.inf) == 0.0
-
-    normals_btag = thaw(op.normal(dcoll, BTAG_ALL), actx)
-    dcoll_normals_btag = thaw(dcoll.normal(BTAG_ALL), actx)
-
-    assert op.norm(dcoll, normals_btag - dcoll_normals_btag, np.inf) == 0.0
-
-# }}}
-
-
 # You can test individual routines by typing
 # $ python test_grudge.py 'test_routine()'
 
diff --git a/test/test_mpi_communication.py b/test/test_mpi_communication.py
index 879d3bf87dc3d4ad8dfeb1c3512f1423ec1589f7..4e3484e9c1bf9c1335761001e872c97eda3907d9 100644
--- a/test/test_mpi_communication.py
+++ b/test/test_mpi_communication.py
@@ -76,7 +76,7 @@ def simple_mpi_communication_entrypoint():
     dcoll = DiscretizationCollection(actx, local_mesh, order=5,
             mpi_communicator=comm)
 
-    x = thaw(op.nodes(dcoll), actx)
+    x = thaw(dcoll.nodes(), actx)
     myfunc = actx.np.sin(np.dot(x, [2, 3]))
 
     from grudge.dof_desc import as_dofdesc
@@ -147,7 +147,7 @@ def mpi_communication_entrypoint():
         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)
+        nodes = thaw(dcoll.nodes(), actx)
         source_center_dist = flat_obj_array(
             [nodes[i] - source_center[i] for i in range(dcoll.dim)]
         )
diff --git a/test/test_op.py b/test/test_op.py
index 2d12409ee3a20c8cfce8c02a539dbd7e31f9e2ed..6aa3ba7915ec4767c9477bde6fb8355aa48ff4ec 100644
--- a/test/test_op.py
+++ b/test/test_op.py
@@ -88,7 +88,7 @@ def test_gradient(actx_factory, form, dim, order, vectorize, nested,
             result[dim-1] = result[dim-1] * (-np.pi/2*actx.np.sin(np.pi/2*x[dim-1]))
             return result
 
-        x = thaw(op.nodes(dcoll), actx)
+        x = thaw(dcoll.nodes(), actx)
 
         if vectorize:
             u = make_obj_array([(i+1)*f(x) for i in range(dim)])
@@ -98,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(op.normal(dcoll, dd), actx)
+            normal = thaw(dcoll.normal(dd), actx)
             u_avg = u_tpair.avg
             if vectorize:
                 if nested:
@@ -212,7 +212,7 @@ def test_divergence(actx_factory, form, dim, order, vectorize, nested,
             result = result + deriv
             return result
 
-        x = thaw(op.nodes(dcoll), actx)
+        x = thaw(dcoll.nodes(), actx)
 
         if vectorize:
             u = make_obj_array([(i+1)*f(x) for i in range(dim)])
@@ -224,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(op.normal(dcoll, dd), actx)
+            normal = thaw(dcoll.normal(dd), actx)
             flux = u_tpair.avg @ normal
             return op.project(dcoll, dd, dd_allfaces, flux)