diff --git a/contrib/maxima/maxwellrectcav3d.mac b/contrib/maxima/maxwellrectcav3d.mac
new file mode 100644
index 0000000000000000000000000000000000000000..abda9687d244b25b3cf546bea9f83383e222bc34
--- /dev/null
+++ b/contrib/maxima/maxwellrectcav3d.mac
@@ -0,0 +1,73 @@
+kill(all);
+load("eigen");
+load("itensor");
+load("diag");
+
+/* -------------------------------------------------------------------------- */
+/* Utilities */
+/* -------------------------------------------------------------------------- */
+mu: 1;
+epsilon: 1;
+
+coords:[x,y,z];
+
+curl(x):=crossfunc(lambda([i,j], diff(x[j], coords[i])));
+div(f):=sum(diff(f[j], coords[j]), j, 1, length(coords));
+
+faraday(max_E, max_H):= curl(max_E) - %i * omega * max_H;
+ampere(max_E, max_H):= curl(max_H) + %i * omega * mu * epsilon * max_E;
+/*
+ampere(max_E, max_H):= curl(max_H) + %i * omega * max_E;
+*/
+
+
+crossfunc(f):=makelist(
+  sum(sum(
+      levi_civita([i,j,k])*f(j,k),
+   j,1,3),k,1,3),i,1,3)$
+
+crossprod(a,b):=crossfunc(lambda([j,k], a[j]*b[k]));
+
+/* -------------------------------------------------------------------------- */
+/* Attempts */
+/* -------------------------------------------------------------------------- */
+nabla_t_squared(f):=diff(f, x, 2) + diff(f, y, 2);
+nabla_t(f):= [diff(f, x, 1) , diff(f, y, 1), 0];
+
+/*
+gamma : sqrt((k_y^2 + k_x^2)/ (mu *epsilon)) ;
+*/
+omega : sqrt(k_x^2 + k_y^2 + k_z^2);
+gamma : sqrt((k_y^2 + k_x^2)) ;
+
+psi_cand:E_0*sin(k_x*x)*sin(k_y*y);
+
+
+
+wave_eqn(f, gam_s):=nabla_t_squared(f) + mu*epsilon*gam_s*f;
+gam_s : gamma^2;
+
+/* The _t indicates transverse components (i.e. x and y components only) */
+/*
+E_t(psi):=(-k_z/gam_s)*sin(k_z * z)* nabla_t(psi);
+H_t(psi):=crossprod((%i * omega /gam_s)*cos(k_z * z)* [0,0,1], nabla_t(psi));
+*/
+E_t(psi):=(-k_z/gam_s)*sin(k_z * z)* nabla_t(psi);
+H_t(psi):=crossprod((%i * omega * epsilon/gam_s)*cos(k_z * z)* [0,0,1], nabla_t(psi));
+
+
+/* These are used as the analytic solution for a rectangular cavity resonator 
+    with travsverse magnetic waves */
+
+E : E_t(psi_cand) + [0,0,psi_cand * cos(k_z * z)];
+H :H_t(psi_cand);
+
+Etime : E * %e ^ (- %i * omega * t);
+Htime : H * %e ^ (- %i * omega * t);
+
+
+trigrat(div(E));
+trigrat(div(H));
+trigrat(faraday(E,H));
+trigrat(ampere(E,H));
+
diff --git a/examples/advection/var-velocity.py b/examples/advection/var-velocity.py
index edce0760f260449574bc84d3c5c4c188c009e2f1..ddc7535288cd82c1bbc56cb8decd9b0644cea73a 100644
--- a/examples/advection/var-velocity.py
+++ b/examples/advection/var-velocity.py
@@ -27,7 +27,7 @@ from pyopencl.tools import (  # noqa
 import logging
 logger = logging.getLogger(__name__)
 
-from grudge import sym, bind, Discretization
+from grudge import sym, bind, DGDiscretizationWithBoundaries
 from pytools.obj_array import join_fields
 
 
@@ -44,7 +44,7 @@ def main(write_output=True, order=4):
     dt_factor = 4
     h = 1/20
 
-    discr = Discretization(cl_ctx, mesh, order=order)
+    discr = DGDiscretizationWithBoundaries(cl_ctx, mesh, order=order)
 
     sym_x = sym.nodes(2)
 
@@ -68,7 +68,7 @@ def main(write_output=True, order=4):
 
     from grudge.models.advection import VariableCoefficientAdvectionOperator
 
-    discr = Discretization(cl_ctx, mesh, order=order)
+    discr = DGDiscretizationWithBoundaries(cl_ctx, mesh, order=order)
     op = VariableCoefficientAdvectionOperator(2, advec_v,
         inflow_u=u_analytic(sym.nodes(dim, sym.BTAG_ALL)),
         flux_type=flux_type)
diff --git a/examples/advection/weak.py b/examples/advection/weak.py
index 967eb95f594f2b21e216bdf06c17ada799fa9e3c..cdadb49e79e85c08a611f856c54970bf3c417b12 100644
--- a/examples/advection/weak.py
+++ b/examples/advection/weak.py
@@ -27,7 +27,7 @@ from pyopencl.tools import (  # noqa
 import logging
 logger = logging.getLogger(__name__)
 
-from grudge import sym, bind, Discretization
+from grudge import sym, bind, DGDiscretizationWithBoundaries
 
 import numpy.linalg as la
 
@@ -48,7 +48,7 @@ def main(write_output=True, order=4):
     dt_factor = 4
     h = 1/20
 
-    discr = Discretization(cl_ctx, mesh, order=order)
+    discr = DGDiscretizationWithBoundaries(cl_ctx, mesh, order=order)
 
     c = np.array([0.1,0.1])
     norm_c = la.norm(c)
@@ -66,7 +66,7 @@ def main(write_output=True, order=4):
     from grudge.models.advection import WeakAdvectionOperator
     from meshmode.mesh import BTAG_ALL, BTAG_NONE
     
-    discr = Discretization(cl_ctx, mesh, order=order)
+    discr = DGDiscretizationWithBoundaries(cl_ctx, mesh, order=order)
     op = WeakAdvectionOperator(c,
         inflow_u=u_analytic(sym.nodes(dim, sym.BTAG_ALL)),
         flux_type=flux_type)
diff --git a/examples/maxwell/analytic_solutions.py b/examples/maxwell/analytic_solutions.py
deleted file mode 100644
index 7ff8884f1d19e46d320e0cc7a8c5301f74d14551..0000000000000000000000000000000000000000
--- a/examples/maxwell/analytic_solutions.py
+++ /dev/null
@@ -1,460 +0,0 @@
-# Copyright (C) 2007 Andreas Kloeckner
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-
-
-from __future__ import division
-from __future__ import absolute_import
-from __future__ import print_function
-from grudge.tools import \
-        cyl_bessel_j, \
-        cyl_bessel_j_prime
-from math import sqrt, pi, sin, cos, atan2, acos
-import numpy
-import numpy.linalg as la
-import cmath
-from six.moves import range
-from six.moves import zip
-
-
-
-
-# solution adapters -----------------------------------------------------------
-class RealPartAdapter:
-    def __init__(self, adaptee):
-        self.adaptee = adaptee
-
-    @property
-    def shape(self):
-        return self.adaptee.shape
-
-    def __call__(self, x, el):
-        return [xi.real for xi in self.adaptee(x, el)]
-
-class SplitComplexAdapter:
-    def __init__(self, adaptee):
-        self.adaptee = adaptee
-
-    @property
-    def shape(self):
-        (n,) = self.adaptee.shape
-        return (n*2,)
-
-    def __call__(self, x, el):
-        ad_x = self.adaptee(x, el)
-        return [xi.real for xi in ad_x] + [xi.imag for xi in ad_x]
-
-
-
-
-class CylindricalFieldAdapter:
-    """Turns a field returned as (r, phi, z) components into
-    (x,y,z) components.
-    """
-    def __init__(self, adaptee):
-        self.adaptee = adaptee
-
-    @property
-    def shape(self):
-        return self.adaptee.shape
-
-    def __call__(self, x, el):
-        xy = x[:2]
-        r = la.norm(xy)
-        phi = atan2(x[1], x[0])
-
-        prev_result = self.adaptee(x, el)
-        result = []
-        i = 0
-        while i < len(prev_result):
-            fr, fphi, fz = prev_result[i:i+3]
-            result.extend([
-                    cos(phi)*fr - sin(phi)*fphi, # ex
-                    sin(phi)*fr + cos(phi)*fphi, # ey
-                    fz,
-                    ])
-            i += 3
-
-        return result
-
-
-
-
-class SphericalFieldAdapter:
-    """Turns the adaptee's field C{(Er, Etheta, Ephi)} components into
-    C{(Ex,Ey,Ez)} components.
-
-    Conventions are those of
-    http://mathworld.wolfram.com/SphericalCoordinates.html
-    """
-    def __init__(self, adaptee):
-        self.adaptee = adaptee
-
-    @property
-    def shape(self):
-        return self.adaptee.shape
-
-    @staticmethod
-    def r_theta_phi_from_xyz(x):
-        r = la.norm(x)
-        return r, atan2(x[1], x[0]), acos(x[2]/r)
-
-    def __call__(self, x, el):
-        r, theta, phi = self.r_theta_phi_from_xyz(x)
-
-        er = numpy.array([
-            cos(theta)*sin(phi),
-            sin(theta)*sin(phi),
-            cos(phi)])
-        etheta = numpy.array([
-            -sin(theta),
-            cos(theta),
-            0])
-        ephi = numpy.array([
-            cos(theta)*cos(phi),
-            sin(theta)*cos(phi),
-            -sin(phi)])
-
-        adaptee_result = self.adaptee(x, el)
-        result = numpy.empty_like(adaptee_result)
-        i = 0
-        while i < len(adaptee_result):
-            fr, ftheta, fphi = adaptee_result[i:i+3]
-            result[i:i+3] = fr*er + ftheta*etheta + fphi*ephi
-            i += 3
-
-        return result
-
-
-
-
-# actual solutions ------------------------------------------------------------
-class CylindricalCavityMode:
-    """A cylindrical TM cavity mode.
-
-    Taken from:
-    J.D. Jackson, Classical Electrodynamics, Wiley.
-    3rd edition, 2001.
-    ch 8.7, p. 368f.
-    """
-    
-    def __init__(self,  m, n, p, radius, height, epsilon, mu):
-        try:
-            from bessel_zeros import bessel_zeros
-        except ImportError:
-            print("*** You need to generate the bessel root data file.")
-            print("*** Execute generate-bessel-zeros.py at the command line.")
-            raise
-
-        assert m >= 0 and m == int(m)
-        assert n >= 1 and n == int(n)
-        assert p >= 0 and p == int(p)
-        self.m = m
-        self.n = n
-        self.p = p
-        self.phi_sign = 1
-
-        R = self.radius = radius
-        d = self.height = height
-
-        self.epsilon = epsilon
-        self.mu = mu
-
-        self.t = 0
-
-        x_mn = bessel_zeros[m][n-1]
-
-        self.omega = 1 / sqrt(mu*epsilon) * sqrt(
-                x_mn**2 / R**2
-                + p**2 * pi**2 / d**2)
-
-        self.gamma_mn = x_mn/R
-
-    def set_time(self, t):
-        self.t = t
-
-    shape = (6,)
-
-    def __call__(self, x, el):
-        # coordinates -----------------------------------------------------
-        xy = x[:2]
-        r = sqrt(xy*xy)
-        phi = atan2(x[1], x[0])
-        z = x[2]
-
-        # copy instance variables for easier access -----------------------
-        m = self.m
-        p = self.p
-        gamma = self.gamma_mn
-        phi_sign = self.phi_sign
-        omega = self.omega
-        d = self.height
-        epsilon = self.epsilon
-
-        # common subexpressions -------------------------------------------
-        tdep = cmath.exp(-1j * omega * self.t)
-        phi_factor = cmath.exp(phi_sign * 1j * m * phi)
-
-        # psi and derivatives ---------------------------------------------
-        psi = cyl_bessel_j(m, gamma * r) * phi_factor
-        psi_dr = gamma*cyl_bessel_j_prime(m, gamma*r) * phi_factor
-        psi_dphi = (cyl_bessel_j(m, gamma * r) 
-                * 1/r * phi_sign*1j*m * phi_factor)
-
-        # field components in polar coordinates ---------------------------
-        ez   = tdep * cos(p * pi * z / d) * psi
-
-        e_transverse_factor = (tdep
-                * (-p*pi/(d*gamma**2))
-                * sin(p * pi * z / d))
-
-        er   = e_transverse_factor * psi_dr
-        ephi = e_transverse_factor * psi_dphi
-
-        hz   = 0j
-
-        # z x grad psi = z x (psi_x, psi_y)   = (-psi_y,   psi_x)
-        # z x grad psi = z x (psi_r, psi_phi) = (-psi_phi, psi_r)
-        h_transverse_factor = (tdep
-                * 1j*epsilon*omega/gamma**2
-                * cos(p * pi * z / d))
-
-        hr   = h_transverse_factor * (-psi_dphi)
-        hphi = h_transverse_factor * psi_dr
-
-        return [er, ephi, ez, hr, hphi, hz]
-
-
-
-
-class RectangularWaveguideMode:
-    """A rectangular TM cavity mode."""
-    
-    def __init__(self, epsilon, mu, mode_indices, 
-            dimensions=(1,1,1), coefficients=(1,0,0),
-            forward_coeff=1, backward_coeff=0):
-        for n in mode_indices:
-            assert n >= 0 and n == int(n)
-        self.mode_indices = mode_indices
-        self.dimensions = dimensions
-        self.coefficients = coefficients
-        self.forward_coeff = forward_coeff
-        self.backward_coeff = backward_coeff
-
-        self.epsilon = epsilon
-        self.mu = mu
-
-        self.t = 0
-
-        self.factors = [n*pi/a for n,  a in zip(self.mode_indices, self.dimensions)]
-
-        c = 1/sqrt(mu*epsilon)
-        self.k = sqrt(sum(f**2 for f in self.factors))
-        self.omega = self.k*c
-
-    def set_time(self, t):
-        self.t = t
-
-    def __call__(self, discr):
-        f,g,h = self.factors
-        omega = self.omega
-        k = self.k
-
-        x = discr.nodes[:,0]
-        y = discr.nodes[:,1]
-        if discr.dimensions > 2:
-            z = discr.nodes[:,2]
-        else:
-            z = discr.volume_zeros()
-
-        sx = numpy.sin(f*x)
-        sy = numpy.sin(g*y)
-        cx = numpy.cos(f*x)
-        cy = numpy.cos(g*y)
-
-        zdep_add = numpy.exp(1j*h*z)+numpy.exp(-1j*h*z)
-        zdep_sub = numpy.exp(1j*h*z)-numpy.exp(-1j*h*z)
-
-        tdep = numpy.exp(-1j * omega * self.t)
-
-        C = 1j/(f**2+g**2)
-
-        result = numpy.zeros((6,len(x)), dtype=zdep_add.dtype)
-
-        result[0] = C*f*h*cx*sy*zdep_sub*tdep # ex
-        result[1] = C*g*h*sx*cy*zdep_sub*tdep # ey
-        result[2] = sx*sy*zdep_add*tdep # ez
-        result[3] = -C*g*self.epsilon*omega*sx*cy*zdep_add*tdep # hx
-        result[4] = C*f*self.epsilon*omega*cx*sy*zdep_add*tdep
-
-        return result
-
-
-
-
-class RectangularCavityMode(RectangularWaveguideMode):
-    """A rectangular TM cavity mode."""
-    
-    def __init__(self, *args, **kwargs):
-        if "scale" in kwargs:
-            kwargs["forward_coeff"] = scale
-            kwargs["backward_coeff"] = scale
-        else:
-            kwargs["forward_coeff"] = 1
-            kwargs["backward_coeff"] = 1
-        RectangularWaveguideMode.__init__(self, *args, **kwargs)
-
-
-
-
-
-# dipole solution -------------------------------------------------------------
-class DipoleFarField:
-    """Dipole EM field.
-
-    See U{http://piki.tiker.net/wiki/Dipole}.
-    """
-    def __init__(self, q, d, omega, epsilon, mu):
-        self.q = q
-        self.d = d
-
-        self.epsilon = epsilon
-        self.mu = mu
-        self.c = c = 1/sqrt(mu*epsilon)
-
-        self.omega = omega
-
-        freq = omega/(2*pi)
-        self.wavelength = c/freq
-
-    def far_field_mask(self, x, el):
-        if la.norm(x) > 0.5*self.wavelength:
-            return 1
-        else:
-            return 0
-
-    def set_time(self, t):
-        self.t = t
-
-    shape = (6,)
-
-    def __call__(self, x, el):
-        # coordinates ---------------------------------------------------------
-        r, theta, phi = SphericalFieldAdapter.r_theta_phi_from_xyz(x)
-
-        # copy instance variables for easier access ---------------------------
-        q = self.q
-        d = self.d
-
-        epsilon = self.epsilon
-        mu = self.mu
-        c = self.c
-
-        omega = self.omega
-        t = self.t
-
-        # field components ----------------------------------------------------
-        tdep = omega*t-omega*r/c
-        e_r  = (2*cos(phi)*q*d) / (4*pi*epsilon) * (
-                1/r**3 * sin(tdep)
-                +omega/(c*r**2) * cos(tdep))
-        e_phi = (sin(phi)*q*d) / (4*pi*epsilon) * (
-                (1/r**3 - omega**2/(c**2*r)*sin(tdep))
-                + omega/(c*r**2)*cos(tdep))
-        h_theta = (omega*sin(phi)*q*d)/(4*pi) * (
-                - omega/(c*r)*sin(tdep)
-                + 1/r**2*cos(tdep))
-
-        return [e_r, 0, e_phi, 0, h_theta, 0]
-
-    def source_modulation(self, t):
-        j0 = self.q*self.d*self.omega/self.epsilon
-        return j0*cos(self.omega*t)
-
-
-
-
-# analytic solution tools -----------------------------------------------------
-def check_time_harmonic_solution(discr, mode, c_sol):
-    from grudge.discretization import bind_nabla, bind_mass_matrix
-    from grudge.visualization import SiloVisualizer
-    from grudge.silo import SiloFile
-    from grudge.tools import dot, cross
-    from grudge.silo import DB_VARTYPE_VECTOR
-
-    def curl(field):
-        return cross(nabla, field)
-
-    vis = SiloVisualizer(discr)
-
-    nabla = bind_nabla(discr)
-    mass = bind_mass_matrix(discr)
-
-    def rel_l2_error(err, base):
-        def l2_norm(field):
-            return sqrt(dot(field, mass*field))
-
-        base_l2 = l2_norm(base)
-        err_l2 = l2_norm(err)
-        if base_l2 == 0:
-            if err_l2 == 0:
-                return 0.
-            else:
-                return float("inf")
-        else:
-            return err_l2/base_l2
-
-    dt = 0.1
-
-    for step in range(10):
-        t = step*dt
-        mode.set_time(t)
-        fields = discr.interpolate_volume_function(c_sol)
-
-        er = fields[0:3]
-        hr = fields[3:6]
-        ei = fields[6:9]
-        hi = fields[9:12]
-
-        silo = SiloFile("em-complex-%04d.silo" % step)
-        vis.add_to_silo(silo,
-                vectors=[
-                    ("curl_er", curl(er)), 
-                    ("om_hi", -mode.mu*mode.omega*hi), 
-                    ("curl_hr", curl(hr)), 
-                    ("om_ei", mode.epsilon*mode.omega*hi), 
-                    ],
-                expressions=[
-                ("diff_er", "curl_er-om_hi", DB_VARTYPE_VECTOR),
-                ("diff_hr", "curl_hr-om_ei", DB_VARTYPE_VECTOR),
-                ],
-                write_coarse_mesh=True,
-                time=t, step=step
-                )
-
-        er_res = curl(er) + mode.mu     *mode.omega*hi
-        ei_res = curl(ei) - mode.mu     *mode.omega*hr
-        hr_res = curl(hr) - mode.epsilon*mode.omega*ei
-        hi_res = curl(hi) + mode.epsilon*mode.omega*er
-
-        print("time=%f, rel l2 residual in Re[E]=%g\tIm[E]=%g\tRe[H]=%g\tIm[H]=%g" % (
-                t,
-                rel_l2_error(er_res, er),
-                rel_l2_error(ei_res, ei),
-                rel_l2_error(hr_res, hr),
-                rel_l2_error(hi_res, hi),
-                ))
-
diff --git a/examples/maxwell/cavities.py b/examples/maxwell/cavities.py
index 6fb7d54ac4eb69cf87705fce00268f68debfd4ba..85a7c3dec0cef99b4a72fe9c23439c4212c0c32d 100644
--- a/examples/maxwell/cavities.py
+++ b/examples/maxwell/cavities.py
@@ -1,260 +1,131 @@
-# Copyright (C) 2007 Andreas Kloeckner
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-from __future__ import division
-from __future__ import absolute_import
-from __future__ import print_function
-import numpy as np
-
-import logging
-logger = logging.getLogger(__name__)
-
-
-def main(write_output=True, allow_features=None, flux_type_arg=1,
-        bdry_flux_type_arg=None, extra_discr_args={}):
-    from grudge.mesh.generator import make_cylinder_mesh, make_box_mesh
-    from grudge.tools import EOCRecorder, to_obj_array
-    from math import sqrt, pi  # noqa
-    from analytic_solutions import (  # noqa
-            RealPartAdapter,
-            SplitComplexAdapter,
-            CylindricalFieldAdapter,
-            CylindricalCavityMode,
-            RectangularWaveguideMode,
-            RectangularCavityMode)
-    from grudge.models.em import MaxwellOperator
-
-    logging.basicConfig(level=logging.DEBUG)
-
-    from grudge.backends import guess_run_context
-    rcon = guess_run_context(allow_features)
-
-    epsilon0 = 8.8541878176e-12  # C**2 / (N m**2)
-    mu0 = 4*pi*1e-7  # N/A**2.
-    epsilon = 1*epsilon0
-    mu = 1*mu0
-
-    eoc_rec = EOCRecorder()
-
-    cylindrical = False
-    periodic = False
-
-    if cylindrical:
-        R = 1
-        d = 2
-        mode = CylindricalCavityMode(m=1, n=1, p=1,
-                radius=R, height=d,
-                epsilon=epsilon, mu=mu)
-        # r_sol = CylindricalFieldAdapter(RealPartAdapter(mode))
-        # c_sol = SplitComplexAdapter(CylindricalFieldAdapter(mode))
-
-        if rcon.is_head_rank:
-            mesh = make_cylinder_mesh(radius=R, height=d, max_volume=0.01)
-    else:
-        if periodic:
-            mode = RectangularWaveguideMode(epsilon, mu, (3, 2, 1))
-            periodicity = (False, False, True)
-        else:
-            periodicity = None
-        mode = RectangularCavityMode(epsilon, mu, (1, 2, 2))
-
-        if rcon.is_head_rank:
-            mesh = make_box_mesh(max_volume=0.001, periodicity=periodicity)
-
-    if rcon.is_head_rank:
-        mesh_data = rcon.distribute_mesh(mesh)
-    else:
-        mesh_data = rcon.receive_mesh()
-
-    for order in [4, 5, 6]:
-    #for order in [1,2,3,4,5,6]:
-        extra_discr_args.setdefault("debug", []).extend([
-            "cuda_no_plan", "cuda_dump_kernels"])
-
-        op = MaxwellOperator(epsilon, mu,
-                flux_type=flux_type_arg,
-                bdry_flux_type=bdry_flux_type_arg)
-
-        discr = rcon.make_discretization(mesh_data, order=order,
-                tune_for=op.sym_operator(),
-                **extra_discr_args)
-
-        from grudge.visualization import VtkVisualizer
-        if write_output:
-            vis = VtkVisualizer(discr, rcon, "em-%d" % order)
-
-        mode.set_time(0)
-
-        def get_true_field():
-            return discr.convert_volume(
-                to_obj_array(mode(discr)
-                    .real.astype(discr.default_scalar_type).copy()),
-                kind=discr.compute_kind)
-
-        fields = get_true_field()
+"""Minimal example of a grudge driver."""
 
-        if rcon.is_head_rank:
-            print("---------------------------------------------")
-            print("order %d" % order)
-            print("---------------------------------------------")
-            print("#elements=", len(mesh.elements))
+from __future__ import division, print_function
 
-        from grudge.timestep.runge_kutta import LSRK4TimeStepper
-        stepper = LSRK4TimeStepper(dtype=discr.default_scalar_type, rcon=rcon)
-        #from grudge.timestep.dumka3 import Dumka3TimeStepper
-        #stepper = Dumka3TimeStepper(3, dtype=discr.default_scalar_type, rcon=rcon)
+__copyright__ = "Copyright (C) 2015 Andreas Kloeckner"
 
-        # {{{ diagnostics setup
+__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:
 
-        from pytools.log import LogManager, add_general_quantities, \
-                add_simulation_quantities, add_run_info
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
 
-        if write_output:
-            log_file_name = "maxwell-%d.dat" % order
-        else:
-            log_file_name = None
+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.
+"""
 
-        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 pytools.log 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)
-        final_time = 0.5e-9
-
-        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))
+import numpy as np
+import pyopencl as cl
+from grudge.shortcuts import set_up_rk4
+from grudge import sym, bind, Discretization
 
-            for step, t, dt in step_it:
-                if step % 50 == 0 and write_output:
-                    sub_timer = vis_timer.start_sub_timer()
-                    e, h = op.split_eh(fields)
-                    visf = vis.make_file("em-%d-%04d" % (order, step))
-                    vis.add_data(
-                            visf,
-                            [
-                                ("e",
-                                    discr.convert_volume(e, kind="numpy")),
-                                ("h",
-                                    discr.convert_volume(h, kind="numpy")),
-                                ],
-                            time=t, step=step)
-                    visf.close()
-                    sub_timer.stop().submit()
+from grudge.models.em import get_rectangular_cavity_mode
 
-                fields = stepper(fields, t, dt, rhs)
 
-            mode.set_time(final_time)
+def main(dims, write_output=True, order=4):
+    cl_ctx = cl.create_some_context()
+    queue = cl.CommandQueue(cl_ctx)
 
-            eoc_rec.add_data_point(order,
-                    discr.norm(fields-get_true_field()))
+    from meshmode.mesh.generation import generate_regular_rect_mesh
+    mesh = generate_regular_rect_mesh(
+            a=(0.0,)*dims,
+            b=(1.0,)*dims,
+            n=(5,)*dims)
 
-        finally:
-            if write_output:
-                vis.close()
+    discr = Discretization(cl_ctx, mesh, order=order)
 
-            logmgr.close()
-            discr.close()
+    if 0:
+        epsilon0 = 8.8541878176e-12  # C**2 / (N m**2)
+        mu0 = 4*np.pi*1e-7  # N/A**2.
+        epsilon = 1*epsilon0
+        mu = 1*mu0
+    else:
+        epsilon = 1
+        mu = 1
 
-        if rcon.is_head_rank:
-            print()
-            print(eoc_rec.pretty_print("P.Deg.", "L2 Error"))
+    from grudge.models.em import MaxwellOperator
+    op = MaxwellOperator(epsilon, mu, flux_type=0.5, dimensions=dims)
 
-        # }}}
+    queue = cl.CommandQueue(discr.cl_context)
 
-    assert eoc_rec.estimate_order_of_convergence()[0, 1] > 6
+    if dims == 3:
+        sym_mode = get_rectangular_cavity_mode(1, (1, 2, 2))
+        fields = bind(discr, sym_mode)(queue, t=0, epsilon=epsilon, mu=mu)
+    else:
+        sym_mode = get_rectangular_cavity_mode(1, (2, 3))
+        fields = bind(discr, sym_mode)(queue, t=0)
 
+    # FIXME
+    #dt = op.estimate_rk4_timestep(discr, fields=fields)
 
-# {{{ entry points for py.test
+    op.check_bc_coverage(mesh)
 
-from pytools.test import mark_test
+    # print(sym.pretty(op.sym_operator()))
+    bound_op = bind(discr, op.sym_operator())
 
+    def rhs(t, w):
+        return bound_op(queue, t=t, w=w)
 
-@mark_test.long
-def test_maxwell_cavities():
-    main(write_output=False)
+    if mesh.dim == 2:
+        dt = 0.004
+    elif mesh.dim == 3:
+        dt = 0.002
 
+    dt_stepper = set_up_rk4("w", dt, fields, rhs)
 
-@mark_test.long
-def test_maxwell_cavities_lf():
-    main(write_output=False, flux_type_arg="lf", bdry_flux_type_arg=1)
+    final_t = dt * 600
+    nsteps = int(final_t/dt)
 
+    print("dt=%g nsteps=%d" % (dt, nsteps))
 
-@mark_test.mpi
-@mark_test.long
-def test_maxwell_cavities_mpi():
-    from pytools.mpi import run_with_mpi_ranks
-    run_with_mpi_ranks(__file__, 2, main,
-            write_output=False, allow_features=["mpi"])
+    from grudge.shortcuts import make_visualizer
+    vis = make_visualizer(discr, vis_order=order)
 
+    step = 0
 
-def test_cuda():
-    try:
-        from pycuda.tools import mark_cuda_test
-    except ImportError:
-        pass
+    norm = bind(discr, sym.norm(2, sym.var("u")))
 
-    yield "SP CUDA Maxwell", mark_cuda_test(
-            do_test_maxwell_cavities_cuda), np.float32
-    yield "DP CUDA Maxwell", mark_cuda_test(
-            do_test_maxwell_cavities_cuda), np.float64
+    from time import time
+    t_last_step = time()
 
+    e, h = op.split_eh(fields)
 
-def do_test_maxwell_cavities_cuda(dtype):
-    import pytest  # noqa
+    if 1:
+        vis.write_vtk_file("fld-%04d.vtu" % step,
+                [
+                    ("e", e),
+                    ("h", h),
+                    ])
 
-    main(write_output=False, allow_features=["cuda"],
-            extra_discr_args=dict(
-                debug=["cuda_no_plan"],
-                default_scalar_type=dtype,
-                ))
+    for event in dt_stepper.run(t_end=final_t):
+        if isinstance(event, dt_stepper.StateComputed):
+            assert event.component_id == "w"
 
-# }}}
+            step += 1
 
+            print(step, event.t, norm(queue, u=e[0]), norm(queue, u=e[1]),
+                    norm(queue, u=h[0]), norm(queue, u=h[1]),
+                    time()-t_last_step)
+            if step % 10 == 0:
+                e, h = op.split_eh(event.state_component)
+                vis.write_vtk_file("fld-%04d.vtu" % step,
+                        [
+                            ("e", e),
+                            ("h", h),
+                            ])
+            t_last_step = time()
 
-# entry point -----------------------------------------------------------------
 
 if __name__ == "__main__":
-    from pytools.mpi import in_mpi_relaunch
-    if in_mpi_relaunch():
-        test_maxwell_cavities_mpi()
-    else:
-        do_test_maxwell_cavities_cuda(np.float32)
+    main(3)
diff --git a/examples/wave/var-propagation-speed.py b/examples/wave/var-propagation-speed.py
index 6e1a7ea7368198871e2852e1c7ff43c51ddb75c0..f161d0346c1349e508b5ed5a32737e1cccf61312 100644
--- a/examples/wave/var-propagation-speed.py
+++ b/examples/wave/var-propagation-speed.py
@@ -28,7 +28,7 @@ THE SOFTWARE.
 import numpy as np
 import pyopencl as cl
 from grudge.shortcuts import set_up_rk4
-from grudge import sym, bind, Discretization
+from grudge import sym, bind, DGDiscretizationWithBoundaries
 
 
 def main(write_output=True, order=4):
@@ -42,7 +42,7 @@ def main(write_output=True, order=4):
             b=(0.5,)*dims,
             n=(20,)*dims)
 
-    discr = Discretization(cl_ctx, mesh, order=order)
+    discr = DGDiscretizationWithBoundaries(cl_ctx, mesh, order=order)
 
     source_center = np.array([0.1, 0.22, 0.33])[:mesh.dim]
     source_width = 0.05
diff --git a/examples/wave/wave-min.py b/examples/wave/wave-min.py
index aa119aa506014ff3a1910fb120d64b8e493e4213..d7ebffd33a7732c621d159622804fed064f365b3 100644
--- a/examples/wave/wave-min.py
+++ b/examples/wave/wave-min.py
@@ -28,7 +28,7 @@ THE SOFTWARE.
 import numpy as np
 import pyopencl as cl
 from grudge.shortcuts import set_up_rk4
-from grudge import sym, bind, Discretization
+from grudge import sym, bind, DGDiscretizationWithBoundaries
 
 
 def main(write_output=True, order=4):
@@ -49,7 +49,7 @@ def main(write_output=True, order=4):
 
     print("%d elements" % mesh.nelements)
 
-    discr = Discretization(cl_ctx, mesh, order=order)
+    discr = DGDiscretizationWithBoundaries(cl_ctx, mesh, order=order)
 
     source_center = np.array([0.1, 0.22, 0.33])[:mesh.dim]
     source_width = 0.05
diff --git a/grudge/__init__.py b/grudge/__init__.py
index c6a5d6b777c5fad20e14cbad4aa2b21a32b8f81b..b854007a48c227d58541a13a972a3a7e25c4e062 100644
--- a/grudge/__init__.py
+++ b/grudge/__init__.py
@@ -24,4 +24,4 @@ THE SOFTWARE.
 
 import grudge.sym  # noqa
 from grudge.execution import bind  # noqa
-from grudge.discretization import Discretization  # noqa
+from grudge.discretization import DGDiscretizationWithBoundaries  # noqa
diff --git a/grudge/discretization.py b/grudge/discretization.py
index b3596acbe2448cf0367df9ddcf9aee18a41678c2..6f39762c00be545aa387253b9f39ea02f5dbeb8a 100644
--- a/grudge/discretization.py
+++ b/grudge/discretization.py
@@ -25,24 +25,18 @@ THE SOFTWARE.
 
 from pytools import memoize_method
 from grudge import sym
+import numpy as np
 
 
+# FIXME Naming not ideal
 class DiscretizationBase(object):
     pass
 
 
-class Discretization(DiscretizationBase):
+class DGDiscretizationWithBoundaries(DiscretizationBase):
     """
-    .. attribute :: volume_discr
-
-    .. automethod :: boundary_connection
-    .. automethod :: boundary_discr
-
-    .. automethod :: interior_faces_connection
-    .. automethod :: interior_faces_discr
-
-    .. automethod :: all_faces_connection
-    .. automethod :: all_faces_discr
+    .. automethod :: discr_from_dd
+    .. automethod :: connection_from_dds
 
     .. autoattribute :: cl_context
     .. autoattribute :: dim
@@ -67,8 +61,8 @@ class Discretization(DiscretizationBase):
 
         from meshmode.discretization import Discretization
 
-        self.volume_discr = Discretization(cl_ctx, mesh,
-                self.get_group_factory_for_quadrature_tag(sym.QTAG_NONE))
+        self._volume_discr = Discretization(cl_ctx, mesh,
+                self.group_factory_for_quadrature_tag(sym.QTAG_NONE))
 
         # {{{ management of discretization-scoped common subexpressions
 
@@ -80,40 +74,169 @@ class Discretization(DiscretizationBase):
 
         # }}}
 
-    def get_group_factory_for_quadrature_tag(self, quadrature_tag):
-        if quadrature_tag is not sym.QTAG_NONE:
-            # FIXME
-            raise NotImplementedError("quadrature")
+    @memoize_method
+    def discr_from_dd(self, dd):
+        dd = sym.as_dofdesc(dd)
+
+        qtag = dd.quadrature_tag
+
+        if dd.is_volume():
+            if qtag is not sym.QTAG_NONE:
+                # FIXME
+                raise NotImplementedError("quadrature")
+            return self._volume_discr
+
+        elif dd.domain_tag is sym.FACE_RESTR_ALL:
+            return self._all_faces_discr(qtag)
+        elif dd.domain_tag is sym.FACE_RESTR_INTERIOR:
+            return self._interior_faces_discr(qtag)
+        elif dd.is_boundary():
+            return self._boundary_discr(dd.domain_tag, qtag)
+        else:
+            raise ValueError("DOF desc tag not understood: " + str(dd))
+
+    @memoize_method
+    def connection_from_dds(self, from_dd, to_dd):
+        from_dd = sym.as_dofdesc(from_dd)
+        to_dd = sym.as_dofdesc(to_dd)
+
+        if from_dd.quadrature_tag is not sym.QTAG_NONE:
+            raise ValueError("cannot interpolate *from* a "
+                    "(non-interpolatory) quadrature grid")
+
+        to_qtag = to_dd.quadrature_tag
+
+        # {{{ simplify domain + qtag change into chained
+
+        if (from_dd.domain_tag != to_dd.domain_tag
+                and from_dd.quadrature_tag != to_dd.quadrature_tag):
+
+            from meshmode.connection import ChainedDiscretizationConnection
+            intermediate_dd = sym.DOFDesc(to_dd.domain_tag)
+            return ChainedDiscretizationConnection(
+                    [
+                        # first change domain
+                        self.connection_from_dds(
+                            from_dd,
+                            intermediate_dd),
+
+                        # then go to quad grid
+                        self.connection_from_dds(
+                            intermediate_dd,
+                            to_dd
+                            )])
+
+        # }}}
+
+        # {{{ generic to-quad
+
+        if (from_dd.domain_tag == to_dd.domain_tag
+                and from_dd.quadrature_tag != to_dd.quadrature_tag):
+            from meshmode.discretization.connection.same_mesh import \
+                    make_same_mesh_connection
+
+            return make_same_mesh_connection(
+                    self.discr_from_dd(to_dd),
+                    self.discr_from_dd(from_dd))
+
+        # }}}
+
+        if from_dd.is_volume():
+            if to_dd.domain_tag is sym.FACE_RESTR_ALL:
+                return self._all_faces_volume_connection(to_qtag)
+            if to_dd.domain_tag is sym.FACE_RESTR_INTERIOR:
+                return self._interior_faces_connection(to_qtag)
+            elif to_dd.is_boundary():
+                assert from_dd.quadrature_tag is sym.QTAG_NONE
+                return self._boundary_connection(to_dd.domain_tag)
+
+            else:
+                raise ValueError("cannot interpolate from volume to: " + str(to_dd))
+
+        elif from_dd.domain_tag is sym.FACE_RESTR_INTERIOR:
+            if to_dd.domain_tag is sym.FACE_RESTR_ALL and to_qtag is sym.QTAG_NONE:
+                return self._all_faces_connection(None)
+            else:
+                raise ValueError(
+                        "cannot interpolate from interior faces to: "
+                        + str(to_dd))
+
+        elif from_dd.domain_tag is sym.FACE_RESTR_ALL:
+            if to_dd.domain_tag is sym.FACE_RESTR_ALL and to_qtag is sym.QTAG_NONE:
+                return self._all_faces_connection(None)
+
+        elif from_dd.is_boundary():
+            if to_dd.domain_tag is sym.FACE_RESTR_ALL and to_qtag is sym.QTAG_NONE:
+                return self._all_faces_connection(from_dd.domain_tag)
+            else:
+                raise ValueError(
+                        "cannot interpolate from interior faces to: "
+                        + str(to_dd))
+
+        else:
+            raise ValueError("cannot interpolate from: " + str(from_dd))
+
+    def group_factory_for_quadrature_tag(self, quadrature_tag):
+        """
+        OK to override in user code to control mode/node choice.
+        """
+
+        if quadrature_tag is None:
+            quadrature_tag = sym.QTAG_NONE
 
         from meshmode.discretization.poly_element import \
-                PolynomialWarpAndBlendGroupFactory
+                PolynomialWarpAndBlendGroupFactory, \
+                QuadratureSimplexGroupFactory
+
+        if quadrature_tag is not sym.QTAG_NONE:
+            return QuadratureSimplexGroupFactory(order=self.order)
+        else:
+            return PolynomialWarpAndBlendGroupFactory(order=self.order)
+
+    @memoize_method
+    def _quad_volume_discr(self, quadrature_tag):
+        from meshmode.discretization import Discretization
 
-        return PolynomialWarpAndBlendGroupFactory(order=self.order)
+        return Discretization(self._volume_discr.cl_context, self._volume_discr.mesh,
+                self.group_factory_for_quadrature_tag(quadrature_tag))
 
     # {{{ boundary
 
     @memoize_method
-    def boundary_connection(self, boundary_tag, quadrature_tag=None):
+    def _boundary_connection(self, boundary_tag):
         from meshmode.discretization.connection import make_face_restriction
         return make_face_restriction(
-                        self.volume_discr,
-                        self.get_group_factory_for_quadrature_tag(quadrature_tag),
+                        self._volume_discr,
+                        self.group_factory_for_quadrature_tag(sym.QTAG_NONE),
                         boundary_tag=boundary_tag)
 
-    def boundary_discr(self, boundary_tag, quadrature_tag=None):
-        return self.boundary_connection(boundary_tag, quadrature_tag).to_discr
+    @memoize_method
+    def _boundary_discr(self, boundary_tag, quadrature_tag=None):
+        if quadrature_tag is None:
+            quadrature_tag = sym.QTAG_NONE
+
+        if quadrature_tag is sym.QTAG_NONE:
+            return self._boundary_connection(boundary_tag).to_discr
+        else:
+            no_quad_bdry_discr = self.boundary_discr(boundary_tag, sym.QTAG_NONE)
+
+            from meshmode.discretization import Discretization
+            return Discretization(
+                    self._volume_discr.cl_context,
+                    no_quad_bdry_discr.mesh,
+                    self.group_factory_for_quadrature_tag(quadrature_tag))
 
     # }}}
 
     # {{{ interior faces
 
     @memoize_method
-    def interior_faces_connection(self, quadrature_tag=None):
+    def _interior_faces_connection(self, quadrature_tag=None):
         from meshmode.discretization.connection import (
                 make_face_restriction, FACE_RESTR_INTERIOR)
         return make_face_restriction(
-                        self.volume_discr,
-                        self.get_group_factory_for_quadrature_tag(quadrature_tag),
+                        self._volume_discr,
+                        self.group_factory_for_quadrature_tag(quadrature_tag),
                         FACE_RESTR_INTERIOR,
 
                         # FIXME: This will need to change as soon as we support
@@ -121,8 +244,8 @@ class Discretization(DiscretizationBase):
                         # types.
                         per_face_groups=False)
 
-    def interior_faces_discr(self, quadrature_tag=None):
-        return self.interior_faces_connection(quadrature_tag).to_discr
+    def _interior_faces_discr(self, quadrature_tag=None):
+        return self._interior_faces_connection(quadrature_tag).to_discr
 
     @memoize_method
     def opposite_face_connection(self, quadrature_tag):
@@ -134,19 +257,19 @@ class Discretization(DiscretizationBase):
                 make_opposite_face_connection
 
         return make_opposite_face_connection(
-                self.interior_faces_connection(quadrature_tag))
+                self._interior_faces_connection(quadrature_tag))
 
     # }}}
 
     # {{{ all-faces
 
     @memoize_method
-    def all_faces_volume_connection(self, quadrature_tag=None):
+    def _all_faces_volume_connection(self, quadrature_tag=None):
         from meshmode.discretization.connection import (
                 make_face_restriction, FACE_RESTR_ALL)
         return make_face_restriction(
-                        self.volume_discr,
-                        self.get_group_factory_for_quadrature_tag(quadrature_tag),
+                        self._volume_discr,
+                        self.group_factory_for_quadrature_tag(quadrature_tag),
                         FACE_RESTR_ALL,
 
                         # FIXME: This will need to change as soon as we support
@@ -154,17 +277,17 @@ class Discretization(DiscretizationBase):
                         # types.
                         per_face_groups=False)
 
-    def all_faces_discr(self, quadrature_tag=None):
-        return self.all_faces_volume_connection(quadrature_tag).to_discr
+    def _all_faces_discr(self, quadrature_tag=None):
+        return self._all_faces_volume_connection(quadrature_tag).to_discr
 
     @memoize_method
-    def all_faces_connection(self, boundary_tag, quadrature_tag=None):
+    def _all_faces_connection(self, boundary_tag):
         """Return a
         :class:`meshmode.discretization.connection.DiscretizationConnection`
         that goes from either
-        :meth:`interior_faces_discr` (if *boundary_tag* is None)
+        :meth:`_interior_faces_discr` (if *boundary_tag* is None)
         or
-        :meth:`boundary_discr` (if *boundary_tag* is not None)
+        :meth:`_boundary_discr` (if *boundary_tag* is not None)
         to a discretization containing all the faces of the volume
         discretization.
         """
@@ -172,37 +295,44 @@ class Discretization(DiscretizationBase):
                 make_face_to_all_faces_embedding
 
         if boundary_tag is None:
-            faces_conn = self.interior_faces_connection(quadrature_tag)
+            faces_conn = self._interior_faces_connection()
         else:
-            faces_conn = self.boundary_connection(boundary_tag, quadrature_tag)
+            faces_conn = self._boundary_connection(boundary_tag)
 
-        return make_face_to_all_faces_embedding(
-                faces_conn, self.all_faces_discr(quadrature_tag))
+        return make_face_to_all_faces_embedding(faces_conn, self._all_faces_discr())
 
     # }}}
 
     @property
     def cl_context(self):
-        return self.volume_discr.cl_context
+        return self._volume_discr.cl_context
 
     @property
     def dim(self):
-        return self.volume_discr.dim
+        return self._volume_discr.dim
 
     @property
     def ambient_dim(self):
-        return self.volume_discr.ambient_dim
+        return self._volume_discr.ambient_dim
+
+    @property
+    def real_dtype(self):
+        return self._volume_discr.real_dtype
+
+    @property
+    def complex_dtype(self):
+        return self._volume_discr.complex_dtype
 
     @property
     def mesh(self):
-        return self.volume_discr.mesh
+        return self._volume_discr.mesh
 
     def empty(self, queue=None, dtype=None, extra_dims=None, allocator=None):
-        return self.volume_discr.empty(queue, dtype, extra_dims=extra_dims,
+        return self._volume_discr.empty(queue, dtype, extra_dims=extra_dims,
                 allocator=allocator)
 
     def zeros(self, queue, dtype=None, extra_dims=None, allocator=None):
-        return self.volume_discr.zeros(queue, dtype, extra_dims=extra_dims,
+        return self._volume_discr.zeros(queue, dtype, extra_dims=extra_dims,
                 allocator=allocator)
 
     def is_volume_where(self, where):
@@ -219,6 +349,11 @@ class PointsDiscretization(DiscretizationBase):
 
     def __init__(self, nodes):
         self._nodes = nodes
+        self.real_dtype = np.dtype(np.float64)
+        self.complex_dtype = np.dtype({
+                np.float32: np.complex64,
+                np.float64: np.complex128
+        }[self.real_dtype.type])
 
     def ambient_dim(self):
         return self._nodes.shape[0]
@@ -234,8 +369,16 @@ class PointsDiscretization(DiscretizationBase):
     def nodes(self):
         return self._nodes
 
-    @property
-    def volume_discr(self):
+    def discr_from_dd(self, dd):
+        dd = sym.as_dofdesc(dd)
+
+        if dd.quadrature_tag is not sym.QTAG_NONE:
+            raise ValueError("quadrature discretization requested from "
+                    "PointsDiscretization")
+        if dd.domain_tag is not sym.DTAG_VOLUME_ALL:
+            raise ValueError("non-volume discretization requested from "
+                    "PointsDiscretization")
+
         return self
 
 
diff --git a/grudge/execution.py b/grudge/execution.py
index 5199f8fbbf67550e0b54b0a70ad0a9fc6a9453d3..1f7e60b8804a35e84ab8d3d0c2d8f37b33502130 100644
--- a/grudge/execution.py
+++ b/grudge/execution.py
@@ -43,45 +43,24 @@ class ExecutionMapper(mappers.Evaluator,
         mappers.LocalOpReducerMixin):
     def __init__(self, queue, context, bound_op):
         super(ExecutionMapper, self).__init__(context)
-        self.discr = bound_op.discr
+        self.discrwb = bound_op.discrwb
         self.bound_op = bound_op
         self.queue = queue
 
-    def get_discr(self, dd):
-        qtag = dd.quadrature_tag
-        if qtag is None:
-            # FIXME: Remove once proper quadrature support arrives
-            qtag = sym.QTAG_NONE
-
-        if dd.is_volume():
-            if qtag is not sym.QTAG_NONE:
-                # FIXME
-                raise NotImplementedError("quadrature")
-            return self.discr.volume_discr
-
-        elif dd.domain_tag is sym.FACE_RESTR_ALL:
-            return self.discr.all_faces_discr(qtag)
-        elif dd.domain_tag is sym.FACE_RESTR_INTERIOR:
-            return self.discr.interior_faces_discr(qtag)
-        elif dd.is_boundary():
-            return self.discr.boundary_discr(dd.domain_tag, qtag)
-        else:
-            raise ValueError("DOF desc tag not understood: " + str(dd))
-
     # {{{ expression mappings -------------------------------------------------
 
     def map_ones(self, expr):
         if expr.dd.is_scalar():
             return 1
 
-        discr = self.get_discr(expr.dd)
+        discr = self.discrwb.discr_from_dd(expr.dd)
 
         result = discr.empty(self.queue, allocator=self.bound_op.allocator)
         result.fill(1)
         return result
 
     def map_node_coordinate_component(self, expr):
-        discr = self.get_discr(expr.dd)
+        discr = self.discrwb.discr_from_dd(expr.dd)
         return discr.nodes()[expr.axis].with_queue(self.queue)
 
     def map_grudge_variable(self, expr):
@@ -89,7 +68,7 @@ class ExecutionMapper(mappers.Evaluator,
 
         value = self.context[expr.name]
         if not expr.dd.is_scalar() and isinstance(value, Number):
-            discr = self.get_discr(expr.dd)
+            discr = self.discrwb.discr_from_dd(expr.dd)
             ary = discr.empty(self.queue)
             ary.fill(value)
             value = ary
@@ -104,7 +83,7 @@ class ExecutionMapper(mappers.Evaluator,
 
             from numbers import Number
             if not dd.is_scalar() and isinstance(value, Number):
-                discr = self.get_discr(dd)
+                discr = self.discrwb.discr_from_dd(dd)
                 ary = discr.empty(self.queue)
                 ary.fill(value)
                 value = ary
@@ -116,14 +95,40 @@ class ExecutionMapper(mappers.Evaluator,
 
         # FIXME: Make a way to register functions
 
-        pars = [self.rec(p) for p in expr.parameters]
-        if any(isinstance(par, cl.array.Array) for par in pars):
-            import pyopencl.clmath as clmath
-            func = getattr(clmath, expr.function.name)
-        else:
+        args = [self.rec(p) for p in expr.parameters]
+        from numbers import Number
+        representative_arg = args[0]
+        if (
+                isinstance(representative_arg,  Number)
+                or (isinstance(representative_arg, np.ndarray)
+                    and representative_arg.shape == ())):
             func = getattr(np, expr.function.name)
+            return func(*args)
 
-        return func(*pars)
+        cached_name = "map_call_knl_"
+
+        i = Variable("i")
+        func = Variable(expr.function.name)
+        if expr.function.name == "fabs":  # FIXME
+            func = Variable("abs")
+            cached_name += "abs"
+        else:
+            cached_name += expr.function.name
+
+        @memoize_in(self.bound_op, cached_name)
+        def knl():
+            knl = lp.make_kernel(
+                "{[i]: 0<=i<n}",
+                [
+                    lp.Assignment(Variable("out")[i],
+                        func(Variable("a")[i]))
+                ], default_offset=lp.auto)
+            return lp.split_iname(knl, "i", 128, outer_tag="g.0", inner_tag="l.0")
+
+        assert len(args) == 1
+        evt, (out,) = knl()(self.queue, a=args[0])
+
+        return out
 
     def map_nodal_sum(self, op, field_expr):
         # FIXME: Could allow array scalars
@@ -200,7 +205,7 @@ class ExecutionMapper(mappers.Evaluator,
             knl = lp.split_iname(knl, "i", 16, inner_tag="l.0")
             return lp.tag_inames(knl, dict(k="g.0"))
 
-        discr = self.get_discr(op.dd_in)
+        discr = self.discrwb.discr_from_dd(op.dd_in)
 
         # FIXME: This shouldn't really assume that it's dealing with a volume
         # input. What about quadrature? What about boundaries?
@@ -237,46 +242,7 @@ class ExecutionMapper(mappers.Evaluator,
         return out
 
     def map_interpolation(self, op, field_expr):
-        if op.dd_in.quadrature_tag not in [None, sym.QTAG_NONE]:
-            raise ValueError("cannot interpolate *from* a quadrature grid")
-
-        dd_in = op.dd_in
-        dd_out = op.dd_out
-
-        qtag = dd_out.quadrature_tag
-        if qtag is None:
-            # FIXME: Remove once proper quadrature support arrives
-            qtag = sym.QTAG_NONE
-
-        if dd_in.is_volume():
-            if dd_out.domain_tag is sym.FACE_RESTR_ALL:
-                conn = self.discr.all_faces_volume_connection(qtag)
-            elif dd_out.domain_tag is sym.FACE_RESTR_INTERIOR:
-                conn = self.discr.interior_faces_connection(qtag)
-            elif dd_out.is_boundary():
-                conn = self.discr.boundary_connection(dd_out.domain_tag, qtag)
-            else:
-                raise ValueError("cannot interpolate from volume to: " + str(dd_out))
-
-        elif dd_in.domain_tag is sym.FACE_RESTR_INTERIOR:
-            if dd_out.domain_tag is sym.FACE_RESTR_ALL:
-                conn = self.discr.all_faces_connection(None, qtag)
-            else:
-                raise ValueError(
-                        "cannot interpolate from interior faces to: "
-                        + str(dd_out))
-
-        elif dd_in.is_boundary():
-            if dd_out.domain_tag is sym.FACE_RESTR_ALL:
-                conn = self.discr.all_faces_connection(dd_in.domain_tag, qtag)
-            else:
-                raise ValueError(
-                        "cannot interpolate from interior faces to: "
-                        + str(dd_out))
-
-        else:
-            raise ValueError("cannot interpolate from: " + str(dd_in))
-
+        conn = self.discrwb.connection_from_dds(op.dd_in, op.dd_out)
         return conn(self.queue, self.rec(field_expr)).with_queue(self.queue)
 
     def map_opposite_partition_face_swap(self, op, field_expr):
@@ -301,7 +267,8 @@ class ExecutionMapper(mappers.Evaluator,
         if qtag is None:
             # FIXME: Remove once proper quadrature support arrives
             qtag = sym.QTAG_NONE
-        return self.discr.opposite_face_connection(qtag)(
+
+        return self.discrwb.opposite_face_connection(qtag)(
                 self.queue, self.rec(field_expr)).with_queue(self.queue)
 
     # {{{ face mass operator
@@ -327,12 +294,7 @@ class ExecutionMapper(mappers.Evaluator,
             knl = lp.split_iname(knl, "i", 16, inner_tag="l.0")
             return lp.tag_inames(knl, dict(k="g.0"))
 
-        qtag = op.dd_in.quadrature_tag
-        if qtag is None:
-            # FIXME: Remove once proper quadrature support arrives
-            qtag = sym.QTAG_NONE
-
-        all_faces_conn = self.discr.all_faces_volume_connection(qtag)
+        all_faces_conn = self.discrwb.connection_from_dds("vol", op.dd_in)
         all_faces_discr = all_faces_conn.to_discr
         vol_discr = all_faces_conn.from_discr
 
@@ -370,14 +332,13 @@ class ExecutionMapper(mappers.Evaluator,
     # }}}
 
     # {{{ code execution functions
-
     def map_insn_loopy_kernel(self, insn):
         kwargs = {}
         kdescr = insn.kernel_descriptor
         for name, expr in six.iteritems(kdescr.input_mappings):
             kwargs[name] = self.rec(expr)
 
-        discr = self.get_discr(kdescr.governing_dd)
+        discr = self.discrwb.discr_from_dd(kdescr.governing_dd)
         for name in kdescr.scalar_args():
             v = kwargs[name]
             if isinstance(v, (int, float)):
@@ -402,14 +363,14 @@ class ExecutionMapper(mappers.Evaluator,
         assignments = []
         for name, expr in zip(insn.names, insn.exprs):
             value = self.rec(expr)
-            self.discr._discr_scoped_subexpr_name_to_value[name] = value
+            self.discrwb._discr_scoped_subexpr_name_to_value[name] = value
             assignments.append((name, value))
 
         return assignments, []
 
     def map_insn_assign_from_discr_scoped(self, insn):
         return [(insn.name,
-            self.discr._discr_scoped_subexpr_name_to_value[insn.name])], []
+            self.discrwb._discr_scoped_subexpr_name_to_value[insn.name])], []
 
     def map_insn_diff_batch_assign(self, insn):
         field = self.rec(insn.field)
@@ -419,13 +380,13 @@ class ExecutionMapper(mappers.Evaluator,
         # This should be unified with map_elementwise_linear, which should
         # be extended to support batching.
 
-        discr = self.get_discr(repr_op.dd_in)
+        discr = self.discrwb.discr_from_dd(repr_op.dd_in)
 
         # FIXME: Enable
         # assert repr_op.dd_in == repr_op.dd_out
         assert repr_op.dd_in.domain_tag == repr_op.dd_out.domain_tag
 
-        @memoize_in(self.discr, "reference_derivative_knl")
+        @memoize_in(self.discrwb, "reference_derivative_knl")
         def knl():
             knl = lp.make_kernel(
                 """{[imatrix,k,i,j]:
@@ -473,8 +434,8 @@ class ExecutionMapper(mappers.Evaluator,
 # {{{ bound operator
 
 class BoundOperator(object):
-    def __init__(self, discr, discr_code, eval_code, debug_flags, allocator=None):
-        self.discr = discr
+    def __init__(self, discrwb, discr_code, eval_code, debug_flags, allocator=None):
+        self.discrwb = discrwb
         self.discr_code = discr_code
         self.eval_code = eval_code
         self.operator_data_cache = {}
@@ -504,13 +465,16 @@ class BoundOperator(object):
 
         from pytools.obj_array import with_object_array_or_scalar
 
-        # {{{ discr-scope evaluation
+        # {{{ discrwb-scope evaluation
 
-        if any(result_var.name not in self.discr._discr_scoped_subexpr_name_to_value
+        if any(
+                (result_var.name not in
+                    self.discrwb._discr_scoped_subexpr_name_to_value)
                 for result_var in self.discr_code.result):
-            # need to do discr-scope evaluation
-            discr_eval_context = {}
-            self.discr_code.execute(ExecutionMapper(queue, discr_eval_context, self))
+            # need to do discrwb-scope evaluation
+            discrwb_eval_context = {}
+            self.discr_code.execute(
+                    ExecutionMapper(queue, discrwb_eval_context, self))
 
         # }}}
 
@@ -518,34 +482,49 @@ class BoundOperator(object):
         for name, var in six.iteritems(context):
             new_context[name] = with_object_array_or_scalar(replace_queue, var)
 
-        return self.eval_code.execute(ExecutionMapper(queue, new_context, self))
+        return self.eval_code.execute(
+                ExecutionMapper(queue, new_context, self))
 
 # }}}
 
 
 # {{{ process_sym_operator function
 
-def process_sym_operator(sym_operator, post_bind_mapper=None,
-        dumper=lambda name, sym_operator: None, mesh=None):
+def process_sym_operator(discrwb, sym_operator, post_bind_mapper=None,
+        dumper=lambda name, sym_operator: None):
 
     import grudge.symbolic.mappers as mappers
 
     dumper("before-bind", sym_operator)
     sym_operator = mappers.OperatorBinder()(sym_operator)
 
-    mappers.ErrorChecker(mesh)(sym_operator)
+    mappers.ErrorChecker(discrwb.mesh)(sym_operator)
 
     if post_bind_mapper is not None:
         dumper("before-postbind", sym_operator)
         sym_operator = post_bind_mapper(sym_operator)
 
-    if mesh is not None:
-        dumper("before-empty-flux-killer", sym_operator)
-        sym_operator = mappers.EmptyFluxKiller(mesh)(sym_operator)
+    dumper("before-empty-flux-killer", sym_operator)
+    sym_operator = mappers.EmptyFluxKiller(discrwb.mesh)(sym_operator)
 
     dumper("before-cfold", sym_operator)
     sym_operator = mappers.CommutativeConstantFoldingMapper()(sym_operator)
 
+    # Work around https://github.com/numpy/numpy/issues/9438
+    #
+    # The idea is that we need 1j as an expression to survive
+    # until code generation time. If it is evaluated and combined
+    # with other constants, we will need to determine its size
+    # (as np.complex64/128) within the expression. But because
+    # of the above numpy bug, sized numbers are not likely to survive
+    # expression building--so that's why we step in here to fix that.
+
+    dumper("before-csize", sym_operator)
+    sym_operator = mappers.ConstantToNumpyConversionMapper(
+            real_type=discrwb.real_dtype.type,
+            complex_type=discrwb.complex_dtype.type,
+            )(sym_operator)
+
     # Ordering restriction:
     #
     # - Must run constant fold before first type inference pass, because zeros,
@@ -566,9 +545,8 @@ def process_sym_operator(sym_operator, post_bind_mapper=None,
     # because otherwise it won't differentiate the type of grids (node or quadrature
     # grids) that the operators will apply on.
 
-    assert mesh is not None
     dumper("before-global-to-reference", sym_operator)
-    sym_operator = mappers.GlobalToReferenceMapper(mesh.ambient_dim)(sym_operator)
+    sym_operator = mappers.GlobalToReferenceMapper(discrwb.ambient_dim)(sym_operator)
 
     dumper("before-distributed", sym_operator)
     from meshmode.distributed import get_connected_partitions
@@ -614,10 +592,11 @@ def bind(discr, sym_operator, post_bind_mapper=lambda x: x,
                     pretty(sym_operator))
             stage[0] += 1
 
-    sym_operator = process_sym_operator(sym_operator,
+    sym_operator = process_sym_operator(
+            discr,
+            sym_operator,
             post_bind_mapper=post_bind_mapper,
-            dumper=dump_optemplate,
-            mesh=discr.mesh)
+            dumper=dump_optemplate)
 
     from grudge.symbolic.compiler import OperatorCompiler
     discr_code, eval_code = OperatorCompiler(discr)(sym_operator)
diff --git a/grudge/models/em.py b/grudge/models/em.py
index 3a2b70cbac3d00dab20420e060ceb073f6c1447a..f0e44f903d226e28710709651e948293a534d1b8 100644
--- a/grudge/models/em.py
+++ b/grudge/models/em.py
@@ -1,11 +1,13 @@
 # -*- coding: utf8 -*-
 """grudge operators modelling electromagnetic phenomena."""
 
-from __future__ import division
-from __future__ import absolute_import
-from six.moves import range
+from __future__ import division, absolute_import
 
-__copyright__ = "Copyright (C) 2007, 2010 Andreas Kloeckner, David Powell"
+__copyright__ = """
+Copyright (C) 2007-2017 Andreas Kloeckner
+Copyright (C) 2010 David Powell
+Copyright (C) 2017 Bogdan Enache
+"""
 
 __license__ = """
 Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -27,11 +29,13 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 THE SOFTWARE.
 """
 
+from six.moves import range
+
 from pytools import memoize_method
 
 from grudge.models import HyperbolicOperator
-from grudge.symbolic.primitives import make_common_subexpression as cse
 from meshmode.mesh import BTAG_ALL, BTAG_NONE
+from grudge import sym
 from pytools.obj_array import join_fields, make_obj_array
 
 # TODO: Check PML
@@ -106,17 +110,8 @@ class MaxwellOperator(HyperbolicOperator):
         self.current = current
         self.incident_bc_data = incident_bc
 
-    @property
-    def c(self):
-        from warnings import warn
-        warn("MaxwellOperator.c is deprecated", DeprecationWarning)
-        if not self.fixed_material:
-            raise RuntimeError("Cannot compute speed of light "
-                    "for non-constant material")
-
-        return 1/(self.mu*self.epsilon)**0.5
 
-    def flux(self, flux_type):
+    def flux(self, w):
         """The template for the numerical flux for variable coefficients.
 
         :param flux_type: can be in [0,1] for anything between central and upwind,
@@ -124,88 +119,72 @@ class MaxwellOperator(HyperbolicOperator):
 
         As per Hesthaven and Warburton page 433.
         """
-        from grudge.flux import (make_normal, FluxVectorPlaceholder,
-                FluxConstantPlaceholder)
 
-        normal = make_normal(self.dimensions)
+        normal = sym.normal(w.dd, self.dimensions)
 
         if self.fixed_material:
-            from grudge.tools import count_subset
-            w = FluxVectorPlaceholder(count_subset(self.get_eh_subset()))
-
             e, h = self.split_eh(w)
-            epsilon = FluxConstantPlaceholder(self.epsilon)
-            mu = FluxConstantPlaceholder(self.mu)
-
-        else:
-            from grudge.tools import count_subset
-            w = FluxVectorPlaceholder(count_subset(self.get_eh_subset())+2)
+            epsilon = self.epsilon
+            mu = self.mu
 
-            epsilon, mu, e, h = self.split_eps_mu_eh(w)
 
-        Z_int = (mu.int/epsilon.int)**0.5
+        Z_int = (mu/epsilon)**0.5
         Y_int = 1/Z_int
-        Z_ext = (mu.ext/epsilon.ext)**0.5
+        Z_ext = (mu/epsilon)**0.5
         Y_ext = 1/Z_ext
 
-        if flux_type == "lf":
+        if self.flux_type == "lf":
             if self.fixed_material:
                 max_c = (self.epsilon*self.mu)**(-0.5)
-            else:
-                from grudge.flux import Max
-                c_int = (epsilon.int*mu.int)**(-0.5)
-                c_ext = (epsilon.ext*mu.ext)**(-0.5)
-                max_c = Max(c_int, c_ext)  # noqa
 
             return join_fields(
                     # flux e,
                     1/2*(
                         -self.space_cross_h(normal, h.int-h.ext)
                         # multiplication by epsilon undoes material divisor below
-                        #-max_c*(epsilon.int*e.int - epsilon.ext*e.ext)
+                        #-max_c*(epsilon*e.int - epsilon*e.ext)
                     ),
                     # flux h
                     1/2*(
                         self.space_cross_e(normal, e.int-e.ext)
                         # multiplication by mu undoes material divisor below
-                        #-max_c*(mu.int*h.int - mu.ext*h.ext)
+                        #-max_c*(mu*h.int - mu*h.ext)
                     ))
-        elif isinstance(flux_type, (int, float)):
+        elif isinstance(self.flux_type, (int, float)):
             # see doc/maxima/maxwell.mac
             return join_fields(
                     # flux e,
                     (
                         -1/(Z_int+Z_ext)*self.space_cross_h(normal,
                             Z_ext*(h.int-h.ext)
-                            - flux_type*self.space_cross_e(normal, e.int-e.ext))
+                            - self.flux_type*self.space_cross_e(normal, e.int-e.ext))
                         ),
                     # flux h
                     (
                         1/(Y_int + Y_ext)*self.space_cross_e(normal,
                             Y_ext*(e.int-e.ext)
-                            + flux_type*self.space_cross_h(normal, h.int-h.ext))
+                            + self.flux_type*self.space_cross_h(normal, h.int-h.ext))
                         ),
                     )
         else:
             raise ValueError("maxwell: invalid flux_type (%s)"
                     % self.flux_type)
 
-    def local_derivatives(self, w=None):
+    def local_derivatives(self, w):
         """Template for the spatial derivatives of the relevant components of
         :math:`E` and :math:`H`
         """
 
-        e, h = self.split_eh(self.field_placeholder(w))
+        e, h = self.split_eh(w)
 
+        nabla = sym.nabla(self.dimensions)
         def e_curl(field):
             return self.space_cross_e(nabla, field)
 
         def h_curl(field):
             return self.space_cross_h(nabla, field)
 
-        from grudge.symbolic import make_nabla
 
-        nabla = make_nabla(self.dimensions)
 
         # in conservation form: u_t + A u_x = 0
         return join_fields(
@@ -213,62 +192,44 @@ class MaxwellOperator(HyperbolicOperator):
                 e_curl(e)
                 )
 
-    def field_placeholder(self, w=None):
-        "A placeholder for E and H."
-        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)
-
-        return w
-
-    def pec_bc(self, w=None):
+    def pec_bc(self, w):
         "Construct part of the flux operator template for PEC boundary conditions"
-        e, h = self.split_eh(self.field_placeholder(w))
+        e, h = self.split_eh(w)
+
+        pec_e = sym.cse(sym.interp("vol", self.pec_tag)(e))
+        pec_h = sym.cse(sym.interp("vol", self.pec_tag)(h))
 
-        from grudge.symbolic import RestrictToBoundary
-        pec_e = RestrictToBoundary(self.pec_tag)(e)
-        pec_h = RestrictToBoundary(self.pec_tag)(h)
 
         return join_fields(-pec_e, pec_h)
 
-    def pmc_bc(self, w=None):
+    def pmc_bc(self, w):
         "Construct part of the flux operator template for PMC boundary conditions"
-        e, h = self.split_eh(self.field_placeholder(w))
+        e, h = self.split_eh(w)
 
-        from grudge.symbolic import RestrictToBoundary
-        pmc_e = RestrictToBoundary(self.pmc_tag)(e)
-        pmc_h = RestrictToBoundary(self.pmc_tag)(h)
+        pmc_e = sym.cse(sym.interp("vol", self.pmc_tag)(e))
+        pmc_h = sym.cse(sym.interp("vol", self.pmc_tag)(h))
 
         return join_fields(pmc_e, -pmc_h)
 
-    def absorbing_bc(self, w=None):
+    def absorbing_bc(self, w):
         """Construct part of the flux operator template for 1st order
         absorbing boundary conditions.
         """
 
-        from grudge.symbolic import normal
-        absorb_normal = normal(self.absorb_tag, self.dimensions)
+        absorb_normal = sym.normal(self.absorb_tag, self.dimensions)
 
-        from grudge.symbolic import RestrictToBoundary, Field
 
-        e, h = self.split_eh(self.field_placeholder(w))
+        e, h = self.split_eh(w)
 
         if self.fixed_material:
             epsilon = self.epsilon
             mu = self.mu
-        else:
-            epsilon = cse(
-                    RestrictToBoundary(self.absorb_tag)(Field("epsilon")))
-            mu = cse(
-                    RestrictToBoundary(self.absorb_tag)(Field("mu")))
 
         absorb_Z = (mu/epsilon)**0.5
         absorb_Y = 1/absorb_Z
 
-        absorb_e = RestrictToBoundary(self.absorb_tag)(e)
-        absorb_h = RestrictToBoundary(self.absorb_tag)(h)
+        absorb_e = sym.cse(sym.interp("vol", self.absorb_tag)(e))
+        absorb_h = sym.cse(sym.interp("vol", self.absorb_tag)(h))
 
         bc = join_fields(
                 absorb_e + 1/2*(self.space_cross_h(absorb_normal, self.space_cross_e(
@@ -281,17 +242,12 @@ class MaxwellOperator(HyperbolicOperator):
 
         return bc
 
-    def incident_bc(self, w=None):
+    def incident_bc(self, w):
         "Flux terms for incident boundary conditions"
         # NOTE: Untested for inhomogeneous materials, but would usually be
         # physically meaningless anyway (are there exceptions to this?)
 
-        e, h = self.split_eh(self.field_placeholder(w))
-        if not self.fixed_material:
-            from warnings import warn
-            if self.incident_tag != BTAG_NONE:
-                warn("Incident boundary conditions assume homogeneous"
-                     " background material, results may be unphysical")
+        e, h = self.split_eh(w)
 
         from grudge.tools import count_subset
         fld_cnt = count_subset(self.get_eh_subset())
@@ -301,7 +257,7 @@ class MaxwellOperator(HyperbolicOperator):
         if is_zero(incident_bc_data):
             return make_obj_array([0]*fld_cnt)
         else:
-            return cse(-incident_bc_data)
+            return sym.cse(-incident_bc_data)
 
     def sym_operator(self, w=None):
         """The full operator template - the high level description of
@@ -310,23 +266,9 @@ class MaxwellOperator(HyperbolicOperator):
         Combines the relevant operator templates for spatial
         derivatives, flux, boundary conditions etc.
         """
-        w = self.field_placeholder(w)
-
-        if self.fixed_material:
-            flux_w = w
-        else:
-            epsilon = self.epsilon
-            mu = self.mu
-
-            flux_w = join_fields(epsilon, mu, w)
-
-        from grudge.symbolic import BoundaryPair, \
-                InverseMassOperator, get_flux_operator
-
-        flux_op = get_flux_operator(self.flux(self.flux_type))
-        bdry_flux_op = get_flux_operator(self.flux(self.bdry_flux_type))
+        from grudge.tools import count_subset
+        w = sym.make_sym_array("w", count_subset(self.get_eh_subset()))
 
-        from grudge.tools.indexing import count_subset
         elec_components = count_subset(self.get_eh_subset()[0:3])
         mag_components = count_subset(self.get_eh_subset()[3:6])
 
@@ -334,10 +276,6 @@ class MaxwellOperator(HyperbolicOperator):
             # need to check this
             material_divisor = (
                     [self.epsilon]*elec_components+[self.mu]*mag_components)
-        else:
-            material_divisor = join_fields(
-                    [epsilon]*elec_components,
-                    [mu]*mag_components)
 
         tags_and_bcs = [
                 (self.pec_tag, self.pec_bc(w)),
@@ -346,67 +284,20 @@ class MaxwellOperator(HyperbolicOperator):
                 (self.incident_tag, self.incident_bc(w)),
                 ]
 
-        def make_flux_bc_vector(tag, bc):
-            if self.fixed_material:
-                return bc
-            else:
-                from grudge.symbolic import RestrictToBoundary
-                return join_fields(
-                        cse(RestrictToBoundary(tag)(epsilon)),
-                        cse(RestrictToBoundary(tag)(mu)),
-                        bc)
+
+        def flux(pair):
+            return sym.interp(pair.dd, "all_faces")(self.flux(pair))
 
         return (
                 - self.local_derivatives(w)
-                + InverseMassOperator()(
-                    flux_op(flux_w)
+                - sym.InverseMassOperator()(sym.FaceMassOperator()(
+                    flux(sym.int_tpair(w))
                     + sum(
-                        bdry_flux_op(BoundaryPair(
-                            flux_w, make_flux_bc_vector(tag, bc), tag))
-                        for tag, bc in tags_and_bcs))
-                    ) / material_divisor
-
-    def bind(self, discr):
-        "Convert the abstract operator template into compiled code."
-        from grudge.mesh import check_bc_coverage
-        check_bc_coverage(discr.mesh, [
-            self.pec_tag, self.absorb_tag, self.incident_tag])
-
-        compiled_sym_operator = discr.compile(self.op_template())
-
-        def rhs(t, w, **extra_context):
-            kwargs = {}
-            kwargs.update(extra_context)
-
-            return compiled_sym_operator(w=w, t=t, **kwargs)
-
-        return rhs
-
-    def assemble_eh(self, e=None, h=None, discr=None):
-        "Combines separate E and H vectors into a single array."
-        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)
+                        flux(sym.bv_tpair(tag, w, bc))
+                            for tag, bc in tags_and_bcs)
+                    ) )) / material_divisor
 
-        return join_fields(e, h)
 
-    assemble_fields = assemble_eh
 
     @memoize_method
     def partial_to_eh_subsets(self):
@@ -445,11 +336,8 @@ class MaxwellOperator(HyperbolicOperator):
         e_idx, h_idx = self.partial_to_eh_subsets()
         e, h = w[e_idx], w[h_idx]
 
-        from grudge.flux import FluxVectorPlaceholder as FVP
-        if isinstance(w, FVP):
-            return FVP(scalars=e), FVP(scalars=h)
-        else:
-            return make_obj_array(e), make_obj_array(h)
+        return e,h
+        #return make_obj_array(e), make_obj_array(h)
 
     def get_eh_subset(self):
         """Return a 6-tuple of :class:`bool` objects indicating whether field
@@ -476,6 +364,14 @@ class MaxwellOperator(HyperbolicOperator):
             raise ValueError("max_eigenvalue is no longer supported for "
                     "variable-coefficient problems--use max_eigenvalue_expr")
 
+    def check_bc_coverage(self, mesh):
+        from meshmode.mesh import check_bc_coverage
+        check_bc_coverage(mesh, [
+            self.pec_tag,
+            self.pmc_tag,
+            self.absorb_tag,
+            self.incident_tag])
+
 
 class TMMaxwellOperator(MaxwellOperator):
     """A 2D TM Maxwell operator with PEC boundaries.
@@ -539,3 +435,61 @@ class SourceFree1DMaxwellOperator(MaxwellOperator):
                 +
                 (False, False, True)
                 )
+
+def get_rectangular_cavity_mode(E_0, mode_indices):
+    """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)
+    if dims != 2 and dims != 3:
+        raise ValueError("Improper mode_indices dimensions")
+    import numpy
+
+    factors = [n*numpy.pi for n in mode_indices]
+
+    kx, ky = factors[0:2]
+    if dims == 3:
+        kz = factors[2]
+
+    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)
+    if dims == 3:
+        sz = sym.sin(kz*z)
+        cz = sym.cos(kz*z)
+
+    if dims == 2:
+        tfac = sym.ScalarVariable("t") * omega
+
+        result = sym.join_fields(
+                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,
+                )
+    else:
+        tdep = sym.exp(-1j * omega * sym.ScalarVariable("t"))
+
+        gamma_squared = ky**2 + kx**2
+        result = sym.join_fields(
+            -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,
+            )
+
+    return result
diff --git a/grudge/shortcuts.py b/grudge/shortcuts.py
index 0e6940ff6f120018a8b5c3c9af29de04eb1bddcb..bb12689bf65208c3a48bb299f2a44c6651c5b58b 100644
--- a/grudge/shortcuts.py
+++ b/grudge/shortcuts.py
@@ -44,13 +44,16 @@ def set_up_rk4(field_var_name, dt, fields, rhs, t_start=0):
     return dt_stepper
 
 
-def make_visualizer(discr, vis_order):
+def make_visualizer(discrwb, vis_order):
     from meshmode.discretization.visualization import make_visualizer
-    with cl.CommandQueue(discr.cl_context) as queue:
-        return make_visualizer(queue, discr.volume_discr, vis_order)
+    with cl.CommandQueue(discrwb.cl_context) as queue:
+        return make_visualizer(queue, discrwb.discr_from_dd("vol"), vis_order)
 
 
-def make_boundary_visualizer(discr, vis_order):
+def make_boundary_visualizer(discrwb, vis_order):
     from meshmode.discretization.visualization import make_visualizer
-    with cl.CommandQueue(discr.cl_context) as queue:
-        return make_visualizer(queue, discr.boundary_discr, vis_order)
+    from grudge import sym
+    with cl.CommandQueue(discrwb.cl_context) as queue:
+        return make_visualizer(
+                queue, discrwb.discr_from_dd(sym.BTAG_ALL),
+                vis_order)
diff --git a/grudge/symbolic/compiler.py b/grudge/symbolic/compiler.py
index ac7765ed919f87ed7b8a17099a302263b94ce347..f6e238083266d4ba0c510243b116b2e49b024631 100644
--- a/grudge/symbolic/compiler.py
+++ b/grudge/symbolic/compiler.py
@@ -852,7 +852,11 @@ class ToLoopyExpressionMapper(mappers.IdentityMapper):
     def map_call(self, expr):
         if isinstance(expr.function, sym.CFunction):
             from pymbolic import var
-            return var(expr.function.name)(
+            func_name = expr.function.name
+            if func_name == "fabs":
+                func_name = "abs"
+
+            return var(func_name)(
                     *[self.rec(par) for par in expr.parameters])
         else:
             raise NotImplementedError(
diff --git a/grudge/symbolic/mappers/__init__.py b/grudge/symbolic/mappers/__init__.py
index a0d16d42ad9b1393592193cc1252ef5a096967d3..ddba6c8d6a301f0c482e2e95ae67e887345d49f3 100644
--- a/grudge/symbolic/mappers/__init__.py
+++ b/grudge/symbolic/mappers/__init__.py
@@ -34,6 +34,7 @@ import pymbolic.mapper.evaluator
 import pymbolic.mapper.dependency
 import pymbolic.mapper.substitutor
 import pymbolic.mapper.constant_folder
+import pymbolic.mapper.constant_converter
 import pymbolic.mapper.flop_counter
 from pymbolic.mapper import CSECachingMapperMixin
 
@@ -893,6 +894,12 @@ class QuadratureUpsamplerChanger(CSECachingMapperMixin, IdentityMapper):
 
 # {{{ simplification / optimization
 
+class ConstantToNumpyConversionMapper(
+        pymbolic.mapper.constant_converter.ConstantToNumpyConversionMapper,
+        IdentityMapperMixin):
+    pass
+
+
 class CommutativeConstantFoldingMapper(
         pymbolic.mapper.constant_folder.CommutativeConstantFoldingMapper,
         IdentityMapperMixin):
diff --git a/grudge/symbolic/operators.py b/grudge/symbolic/operators.py
index c4f6ed659613006337d82bf6e1e477437f2642e0..76bd938c941d5b7e0eea5682f164f188f3e2aff0 100644
--- a/grudge/symbolic/operators.py
+++ b/grudge/symbolic/operators.py
@@ -540,10 +540,10 @@ def norm(p, arg, dd=None):
         return sym.CFunction("sqrt")(norm_squared)
 
     elif p == np.Inf:
-        result = sym.NodalMax()(sym.CFunction("fabs")(arg))
+        result = sym.NodalMax(dd_in=dd)(sym.CFunction("fabs")(arg))
         from pymbolic.primitives import Max
 
-        if isinstance(norm_squared, np.ndarray):
+        if isinstance(result, np.ndarray):
             from functools import reduce
             result = reduce(Max, result)
 
diff --git a/grudge/symbolic/primitives.py b/grudge/symbolic/primitives.py
index 761fc7a7b6e46b6c50786ceeb5e355c4166a51a6..3281abef525697ee692aacba76a18ba39a574664 100644
--- a/grudge/symbolic/primitives.py
+++ b/grudge/symbolic/primitives.py
@@ -185,6 +185,7 @@ class DOFDesc(object):
         elif isinstance(domain_tag, BTAG_PARTITION):
             pass
         elif domain_tag in [BTAG_ALL, BTAG_REALLY_ALL, BTAG_NONE]:
+            # FIXME: Should wrap these in DTAG_BOUNDARY
             pass
         elif isinstance(domain_tag, DTAG_BOUNDARY):
             pass
diff --git a/grudge/tools.py b/grudge/tools.py
index cd1cc49f1828ba82f95c0e805262315e5f8e2be6..88d6714364e250d137ee7988558c91138acc1eb7 100644
--- a/grudge/tools.py
+++ b/grudge/tools.py
@@ -25,6 +25,7 @@ THE SOFTWARE.
 """
 
 import numpy as np
+from pytools import levi_civita
 
 
 def is_zero(x):
@@ -36,3 +37,90 @@ def is_zero(x):
         return x == 0
     else:
         return False
+
+
+class SubsettableCrossProduct:
+    """A cross product that can operate on an arbitrary subsets of its
+    two operands and return an arbitrary subset of its result.
+    """
+
+    full_subset = (True, True, True)
+
+    def __init__(self, op1_subset=full_subset, op2_subset=full_subset,
+            result_subset=full_subset):
+        """Construct a subset-able cross product.
+        :param op1_subset: The subset of indices of operand 1 to be taken into
+            account.  Given as a 3-sequence of bools.
+        :param op2_subset: The subset of indices of operand 2 to be taken into
+            account.  Given as a 3-sequence of bools.
+        :param result_subset: The subset of indices of the result that are
+            calculated.  Given as a 3-sequence of bools.
+        """
+        def subset_indices(subset):
+            return [i for i, use_component in enumerate(subset)
+                    if use_component]
+
+        self.op1_subset = op1_subset
+        self.op2_subset = op2_subset
+        self.result_subset = result_subset
+
+        import pymbolic
+        op1 = pymbolic.var("x")
+        op2 = pymbolic.var("y")
+
+        self.functions = []
+        self.component_lcjk = []
+        for i, use_component in enumerate(result_subset):
+            if use_component:
+                this_expr = 0
+                this_component = []
+                for j, j_real in enumerate(subset_indices(op1_subset)):
+                    for k, k_real in enumerate(subset_indices(op2_subset)):
+                        lc = levi_civita((i, j_real, k_real))
+                        if lc != 0:
+                            this_expr += lc*op1.index(j)*op2.index(k)
+                            this_component.append((lc, j, k))
+                self.functions.append(pymbolic.compile(this_expr,
+                    variables=[op1, op2]))
+                self.component_lcjk.append(this_component)
+
+    def __call__(self, x, y, three_mult=None):
+        """Compute the subsetted cross product on the indexables *x* and *y*.
+        :param three_mult: a function of three arguments *sign, xj, yk*
+          used in place of the product *sign*xj*yk*. Defaults to just this
+          product if not given.
+        """
+        from pytools.obj_array import join_fields
+        if three_mult is None:
+            return join_fields(*[f(x, y) for f in self.functions])
+        else:
+            return join_fields(
+                    *[sum(three_mult(lc, x[j], y[k]) for lc, j, k in lcjk)
+                    for lcjk in self.component_lcjk])
+
+
+cross = SubsettableCrossProduct()
+
+
+def count_subset(subset):
+    from pytools import len_iterable
+    return len_iterable(uc for uc in subset if uc)
+
+
+def partial_to_all_subset_indices(subsets, base=0):
+    """Takes a sequence of bools and generates it into an array of indices
+    to be used to insert the subset into the full set.
+    Example:
+    >>> list(partial_to_all_subset_indices([[False, True, True], [True,False,True]]))
+    [array([0 1]), array([2 3]
+    """
+
+    idx = base
+    for subset in subsets:
+        result = []
+        for is_in in subset:
+            if is_in:
+                result.append(idx)
+                idx += 1
+
+        yield np.array(result, dtype=np.intp)
diff --git a/test/test_grudge.py b/test/test_grudge.py
index bd47f823c36931290c1b7ba9981c5c8b41a7a032..84c46168ede26efe1a42935501d69fba99b7700a 100644
--- a/test/test_grudge.py
+++ b/test/test_grudge.py
@@ -37,7 +37,7 @@ from pyopencl.tools import (  # noqa
 import logging
 logger = logging.getLogger(__name__)
 
-from grudge import sym, bind, Discretization
+from grudge import sym, bind, DGDiscretizationWithBoundaries
 
 
 @pytest.mark.parametrize("dim", [2, 3])
@@ -64,7 +64,7 @@ def test_inverse_metric(ctx_factory, dim):
     from meshmode.mesh.processing import map_mesh
     mesh = map_mesh(mesh, m)
 
-    discr = Discretization(cl_ctx, mesh, order=4)
+    discr = DGDiscretizationWithBoundaries(cl_ctx, mesh, order=4)
 
     sym_op = (
             sym.forward_metric_derivative_mat(mesh.dim)
@@ -98,7 +98,7 @@ def test_1d_mass_mat_trig(ctx_factory):
     mesh = generate_regular_rect_mesh(a=(-4*np.pi,), b=(9*np.pi,),
             n=(17,), order=1)
 
-    discr = Discretization(cl_ctx, mesh, order=8)
+    discr = DGDiscretizationWithBoundaries(cl_ctx, mesh, order=8)
 
     x = sym.nodes(1)
     f = bind(discr, sym.cos(x[0])**2)(queue)
@@ -139,7 +139,7 @@ def test_tri_diff_mat(ctx_factory, dim, order=4):
         mesh = generate_regular_rect_mesh(a=(-0.5,)*dim, b=(0.5,)*dim,
                 n=(n,)*dim, order=4)
 
-        discr = Discretization(cl_ctx, mesh, order=4)
+        discr = DGDiscretizationWithBoundaries(cl_ctx, mesh, order=4)
         nabla = sym.nabla(dim)
 
         for axis in range(dim):
@@ -182,7 +182,7 @@ def test_2d_gauss_theorem(ctx_factory):
     cl_ctx = cl.create_some_context()
     queue = cl.CommandQueue(cl_ctx)
 
-    discr = Discretization(cl_ctx, mesh, order=2)
+    discr = DGDiscretizationWithBoundaries(cl_ctx, mesh, order=2)
 
     def f(x):
         return sym.join_fields(
@@ -272,7 +272,7 @@ def test_convergence_advec(ctx_factory, mesh_name, mesh_pars, op_type, flux_type
 
         from grudge.models.advection import (
                 StrongAdvectionOperator, WeakAdvectionOperator)
-        discr = Discretization(cl_ctx, mesh, order=order)
+        discr = DGDiscretizationWithBoundaries(cl_ctx, mesh, order=order)
         op_class = {
                 "strong": StrongAdvectionOperator,
                 "weak": WeakAdvectionOperator,
@@ -333,6 +333,76 @@ def test_convergence_advec(ctx_factory, mesh_name, mesh_pars, op_type, flux_type
     assert eoc_rec.order_estimate() > order
 
 
+@pytest.mark.parametrize("order", [3, 4, 5])
+# test: 'test_convergence_advec(cl._csc, "disk", [0.1, 0.05], "strong", "upwind", 3)'
+def test_convergence_maxwell(ctx_factory,  order, visualize=False):
+    """Test whether 3D maxwells actually converges"""
+
+    cl_ctx = cl.create_some_context()
+    queue = cl.CommandQueue(cl_ctx)
+
+    from pytools.convergence import EOCRecorder
+    eoc_rec = EOCRecorder()
+
+    dims = 3
+    ns = [4, 6, 8]
+    for n in ns:
+        from meshmode.mesh.generation import generate_regular_rect_mesh
+        mesh = generate_regular_rect_mesh(
+                a=(0.0,)*dims,
+                b=(1.0,)*dims,
+                n=(n,)*dims)
+
+        discr = DGDiscretizationWithBoundaries(cl_ctx, 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(queue, t=0, epsilon=epsilon, mu=mu)
+
+        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())
+
+        def rhs(t, w):
+            return bound_op(queue, t=t, w=w)
+
+        dt = 0.002
+        final_t = dt * 5
+        nsteps = int(final_t/dt)
+
+        from grudge.shortcuts import set_up_rk4
+        dt_stepper = set_up_rk4("w", dt, fields, rhs)
+
+        print("dt=%g nsteps=%d" % (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):
+                assert event.component_id == "w"
+                esc = event.state_component
+
+                step += 1
+                print(step)
+
+        sol = analytic_sol(queue, mu=mu, epsilon=epsilon, t=step * dt)
+        vals = [norm(queue, u=(esc[i] - sol[i])) / norm(queue, u=sol[i]) for i in range(5)] # noqa E501
+        total_error = sum(vals)
+        eoc_rec.add_data_point(1.0/n, total_error)
+
+    print(eoc_rec.pretty_print(abscissa_label="h",
+            error_label="L2 Error"))
+
+    assert eoc_rec.order_estimate() > order
+
+
 def test_foreign_points(ctx_factory):
     pytest.importorskip("sumpy")
     import sumpy.point_calculus as pc