diff --git a/grudge/models/em.py b/grudge/models/em.py
index 3f4bc13d42a017fcee36ba7b05237c512ca11451..a3fc5b323cde4f46764758ea3f704eb30b2f908c 100644
--- a/grudge/models/em.py
+++ b/grudge/models/em.py
@@ -29,10 +29,15 @@ THE SOFTWARE.
 from pytools import memoize_method
 
 from grudge.models import HyperbolicOperator
+from grudge.symbolic.primitives import TracePair
+
+from meshmode.dof_array import thaw
 from meshmode.mesh import BTAG_ALL, BTAG_NONE
-from grudge import sym
+
 from pytools.obj_array import flat_obj_array, make_obj_array
 
+import grudge.op as op
+
 
 class MaxwellOperator(HyperbolicOperator):
     """A strong-form 3D Maxwell operator which supports fixed or variable
@@ -43,7 +48,7 @@ class MaxwellOperator(HyperbolicOperator):
 
     _default_dimensions = 3
 
-    def __init__(self, epsilon, mu,
+    def __init__(self, dcoll, epsilon, mu,
             flux_type,
             bdry_flux_type=None,
             pec_tag=BTAG_ALL,
@@ -66,6 +71,7 @@ class MaxwellOperator(HyperbolicOperator):
             boundary condition
         """
 
+        self.dcoll = dcoll
         self.dimensions = dimensions or self._default_dimensions
 
         space_subset = [True]*self.dimensions + [False]*(3-self.dimensions)
@@ -103,7 +109,7 @@ class MaxwellOperator(HyperbolicOperator):
         self.current = current
         self.incident_bc_data = incident_bc
 
-    def flux(self, w):
+    def flux(self, wtpair):
         """The numerical flux for variable coefficients.
 
         :param flux_type: can be in [0,1] for anything between central and upwind,
@@ -112,10 +118,10 @@ class MaxwellOperator(HyperbolicOperator):
         As per Hesthaven and Warburton page 433.
         """
 
-        normal = sym.normal(w.dd, self.dimensions)
+        normal = thaw(self.dcoll._setup_actx, op.normal(self.dcoll, wtpair.dd))
 
         if self.fixed_material:
-            e, h = self.split_eh(w)
+            e, h = self.split_eh(wtpair)
             epsilon = self.epsilon
             mu = self.mu
 
@@ -168,13 +174,18 @@ class MaxwellOperator(HyperbolicOperator):
 
         e, h = self.split_eh(w)
 
-        nabla = sym.nabla(self.dimensions)
+        # Object array of derivative operators
+        nabla = flat_obj_array(
+            [_Dx(self.dcoll, i) for i in range(self.dimensions)]
+        )
 
         def e_curl(field):
-            return self.space_cross_e(nabla, field)
+            return self.space_cross_e(nabla, field,
+                                      three_mult=lambda lc, x, y: lc * (x * y))
 
         def h_curl(field):
-            return self.space_cross_h(nabla, field)
+            return self.space_cross_h(nabla, field,
+                                      three_mult=lambda lc, x, y: lc * (x * y))
 
         # in conservation form: u_t + A u_x = 0
         return flat_obj_array(
@@ -187,8 +198,8 @@ class MaxwellOperator(HyperbolicOperator):
         """
         e, h = self.split_eh(w)
 
-        pec_e = sym.cse(sym.project("vol", self.pec_tag)(e))
-        pec_h = sym.cse(sym.project("vol", self.pec_tag)(h))
+        pec_e = op.project(self.dcoll, "vol", self.pec_tag, e)
+        pec_h = op.project(self.dcoll, "vol", self.pec_tag, h)
 
         return flat_obj_array(-pec_e, pec_h)
 
@@ -197,8 +208,8 @@ class MaxwellOperator(HyperbolicOperator):
         """
         e, h = self.split_eh(w)
 
-        pmc_e = sym.cse(sym.project("vol", self.pmc_tag)(e))
-        pmc_h = sym.cse(sym.project("vol", self.pmc_tag)(h))
+        pmc_e = op.project(self.dcoll, "vol", self.pmc_tag, e)
+        pmc_h = op.project(self.dcoll, "vol", self.pmc_tag, h)
 
         return flat_obj_array(pmc_e, -pmc_h)
 
@@ -207,7 +218,8 @@ class MaxwellOperator(HyperbolicOperator):
         absorbing boundary conditions.
         """
 
-        absorb_normal = sym.normal(self.absorb_tag, self.dimensions)
+        absorb_normal = thaw(self.dcoll._setup_actx,
+                             op.normal(self.dcoll, dd=self.absorb_tag))
 
         e, h = self.split_eh(w)
 
@@ -218,8 +230,8 @@ class MaxwellOperator(HyperbolicOperator):
         absorb_Z = (mu/epsilon)**0.5  # noqa: N806
         absorb_Y = 1/absorb_Z  # noqa: N806
 
-        absorb_e = sym.cse(sym.project("vol", self.absorb_tag)(e))
-        absorb_h = sym.cse(sym.project("vol", self.absorb_tag)(h))
+        absorb_e = op.project(self.dcoll, "vol", self.absorb_tag, e)
+        absorb_h = op.project(self.dcoll, "vol", self.absorb_tag, h)
 
         bc = flat_obj_array(
                 absorb_e + 1/2*(self.space_cross_h(absorb_normal, self.space_cross_e(
@@ -247,9 +259,9 @@ class MaxwellOperator(HyperbolicOperator):
         if is_zero(incident_bc_data):
             return make_obj_array([0]*fld_cnt)
         else:
-            return sym.cse(-incident_bc_data)
+            return -incident_bc_data
 
-    def sym_operator(self, w=None):
+    def operator(self, t, w):
         """The full operator template - the high level description of
         the Maxwell operator.
 
@@ -257,7 +269,6 @@ class MaxwellOperator(HyperbolicOperator):
         derivatives, flux, boundary conditions etc.
         """
         from grudge.tools import count_subset
-        w = sym.make_sym_array("w", count_subset(self.get_eh_subset()))
 
         elec_components = count_subset(self.get_eh_subset()[0:3])
         mag_components = count_subset(self.get_eh_subset()[3:6])
@@ -274,17 +285,27 @@ class MaxwellOperator(HyperbolicOperator):
                 (self.incident_tag, self.incident_bc(w)),
                 ]
 
+        dcoll = self.dcoll
+
         def flux(pair):
-            return sym.project(pair.dd, "all_faces")(self.flux(pair))
+            return op.project(dcoll, pair.dd, "all_faces", self.flux(pair))
+
+        def bv_tpair(tag, w, bc):
+            return TracePair(tag, interior=op.project(dcoll, "vol", tag, w),
+                             exterior=bc)
 
         return (
-                - self.local_derivatives(w)
-                - sym.InverseMassOperator()(sym.FaceMassOperator()(
-                    flux(sym.int_tpair(w))
-                    + sum(
-                        flux(sym.bv_tpair(tag, w, bc))
-                        for tag, bc in tags_and_bcs)
-                    ))) / material_divisor
+            - self.local_derivatives(w)
+            - op.inverse_mass(
+                dcoll,
+                op.face_mass(
+                    dcoll,
+                    flux(op.interior_trace_pair(dcoll, w))
+                    + sum(flux(bv_tpair(tag, w, bc))
+                          for tag, bc in tags_and_bcs)
+                )
+            )
+        ) / material_divisor
 
     @memoize_method
     def partial_to_eh_subsets(self):
@@ -322,10 +343,8 @@ class MaxwellOperator(HyperbolicOperator):
         if self.fixed_material:
             return 1/sqrt(self.epsilon*self.mu)  # a number
         else:
-            import grudge.symbolic as sym
-            return sym.NodalMax("vol")(
-                    1 / sym.sqrt(self.epsilon * self.mu)
-                    )
+            actx = self.dcoll._setup_actx
+            return op.nodal_maximum(1 / actx.np.sqrt(self.epsilon * self.mu))
 
     def max_eigenvalue(self, t, fields=None, discr=None, context=None):
         if context is None:
@@ -345,6 +364,17 @@ class MaxwellOperator(HyperbolicOperator):
             self.incident_tag])
 
 
+# NOTE: Hack for getting the derivative operators to play nice
+# with grudge.tools.SubsettableCrossProduct
+class _Dx:
+    def __init__(self, dcoll, i):
+        self.dcoll = dcoll
+        self.i = i
+
+    def __mul__(self, other):
+        return op.local_d_dx(self.dcoll, self.i, other)
+
+
 class TMMaxwellOperator(MaxwellOperator):
     """A 2D TM Maxwell operator with PEC boundaries.
 
@@ -405,7 +435,7 @@ class SourceFree1DMaxwellOperator(MaxwellOperator):
                 )
 
 
-def get_rectangular_cavity_mode(E_0, mode_indices):  # noqa: N803
+def get_rectangular_cavity_mode(actx, nodes, t, E_0, mode_indices):  # noqa: N803
     """A rectangular TM cavity mode for a rectangle / cube
     with one corner at the origin and the other at (1,1[,1])."""
     dims = len(mode_indices)
@@ -421,43 +451,44 @@ def get_rectangular_cavity_mode(E_0, mode_indices):  # noqa: N803
 
     omega = numpy.sqrt(sum(f**2 for f in factors))
 
-    nodes = sym.nodes(dims)
     x = nodes[0]
     y = nodes[1]
     if dims == 3:
         z = nodes[2]
 
-    sx = sym.sin(kx*x)
-    cx = sym.cos(kx*x)
-    sy = sym.sin(ky*y)
-    cy = sym.cos(ky*y)
+    zeros = 0*x
+    sx = actx.np.sin(kx*x)
+    cx = actx.np.cos(kx*x)
+    sy = actx.np.sin(ky*y)
+    cy = actx.np.cos(ky*y)
     if dims == 3:
-        sz = sym.sin(kz*z)
-        cz = sym.cos(kz*z)
+        sz = actx.np.sin(kz*z)
+        cz = actx.np.cos(kz*z)
 
     if dims == 2:
-        tfac = sym.ScalarVariable("t") * omega
+        tfac = t * omega
 
         result = flat_obj_array(
-            0,
-            0,
-            sym.sin(kx * x) * sym.sin(ky * y) * sym.cos(tfac),  # ez
-            -ky * sym.sin(kx * x) * sym.cos(ky * y) * sym.sin(tfac) / omega,  # hx
-            kx * sym.cos(kx * x) * sym.sin(ky * y) * sym.sin(tfac) / omega,  # hy
-            0,
-            )
+            zeros,
+            zeros,
+            actx.np.sin(kx * x) * actx.np.sin(ky * y) * actx.np.cos(tfac),  # ez
+            (-ky * actx.np.sin(kx * x) * actx.np.cos(ky * y)
+             * actx.np.sin(tfac) / omega),  # hx
+            (kx * actx.np.cos(kx * x) * actx.np.sin(ky * y)
+             * actx.np.sin(tfac) / omega),  # hy
+            zeros,
+        )
     else:
-        tdep = sym.exp(-1j * omega * sym.ScalarVariable("t"))
+        tdep = numpy.exp(-1j * omega * t)
 
         gamma_squared = ky**2 + kx**2
         result = flat_obj_array(
             -kx * kz * E_0*cx*sy*sz*tdep / gamma_squared,  # ex
             -ky * kz * E_0*sx*cy*sz*tdep / gamma_squared,  # ey
             E_0 * sx*sy*cz*tdep,  # ez
-
             -1j * omega * ky*E_0*sx*cy*cz*tdep / gamma_squared,  # hx
             1j * omega * kx*E_0*cx*sy*cz*tdep / gamma_squared,
-            0,
-            )
+            zeros,
+        )
 
     return result