diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 7deb4fae9ab9e7df8ec06142658d8cd6fca53356..8373a93d2b77d803ba2d7d54c8d84cfe91ebd078 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -11,6 +11,18 @@ Python 3.5 POCL:
   except:
   - tags
 
+Python 3.5 Conda:
+  script:
+  - export SUMPY_NO_CACHE=1
+  - CONDA_ENVIRONMENT=.test-conda-env-py3.yml
+  - REQUIREMENTS_TXT=.test-conda-env-py3-requirements.txt
+  - curl -L -O -k https://gitlab.tiker.net/inducer/ci-support/raw/master/build-and-test-py-project-within-miniconda.sh
+  - ". ./build-and-test-py-project-within-miniconda.sh"
+  tags:
+  - linux
+  except:
+  - tags
+
 Python 2.7 POCL:
   script:
   - export PY_EXE=python2.7
diff --git a/.test-conda-env-py3-requirements.txt b/.test-conda-env-py3-requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..7cefac6831754070d40eb8427b09f70a91b498cd
--- /dev/null
+++ b/.test-conda-env-py3-requirements.txt
@@ -0,0 +1,5 @@
+git+https://github.com/inducer/boxtree
+git+https://github.com/inducer/pymbolic
+git+https://github.com/inducer/loopy
+git+https://github.com/inducer/sumpy
+git+https://github.com/inducer/meshmode
diff --git a/.test-conda-env-py3.yml b/.test-conda-env-py3.yml
new file mode 100644
index 0000000000000000000000000000000000000000..c098bd19824933d4891d473594390398f659dee2
--- /dev/null
+++ b/.test-conda-env-py3.yml
@@ -0,0 +1,16 @@
+name: test-conda-env-py3
+channels:
+- inducer
+- symengine/label/dev
+- conda-forge
+- defaults
+dependencies:
+- git
+- conda-forge::numpy
+- conda-forge::sympy
+- pocl=0.13
+- islpy
+- pyopencl
+- python=3.5
+- python-symengine=0.2.0.53.g83912b7=py35_1
+# things not in here: loopy boxtree pymbolic pyfmmlib meshmode sumpy
diff --git a/doc/qbx.rst b/doc/qbx.rst
index d297db5eeb540327dc159c0b02e0316f86f5939b..46244da1086be469757fed4251ebdd946ed83af3 100644
--- a/doc/qbx.rst
+++ b/doc/qbx.rst
@@ -3,6 +3,11 @@
 QBX internals
 =============
 
+Refinement
+----------
+
+.. automodule:: pytential.qbx.refinement
+
 Data structures describing geometry
 -----------------------------------
 
diff --git a/examples/cahn-hilliard.py b/examples/cahn-hilliard.py
new file mode 100644
index 0000000000000000000000000000000000000000..f7cd6559f06878bbfc7811fec1f6c5d814afc193
--- /dev/null
+++ b/examples/cahn-hilliard.py
@@ -0,0 +1,132 @@
+import numpy as np
+import pyopencl as cl
+import pyopencl.clmath  # noqa
+
+from meshmode.discretization import Discretization
+from meshmode.discretization.poly_element import \
+        InterpolatoryQuadratureSimplexGroupFactory
+
+from pytential import bind, sym, norm  # noqa
+from pytential.target import PointsTarget
+
+# {{{ set some constants for use below
+
+nelements = 20
+bdry_quad_order = 4
+mesh_order = bdry_quad_order
+qbx_order = bdry_quad_order
+bdry_ovsmp_quad_order = 4*bdry_quad_order
+fmm_order = 8
+
+# }}}
+
+
+def main():
+    import logging
+    logging.basicConfig(level=logging.INFO)
+
+    cl_ctx = cl.create_some_context()
+    queue = cl.CommandQueue(cl_ctx)
+
+    from meshmode.mesh.generation import ellipse, make_curve_mesh
+    from functools import partial
+
+    mesh = make_curve_mesh(
+                partial(ellipse, 2),
+                np.linspace(0, 1, nelements+1),
+                mesh_order)
+
+    pre_density_discr = Discretization(
+            cl_ctx, mesh,
+            InterpolatoryQuadratureSimplexGroupFactory(bdry_quad_order))
+
+    from pytential.qbx import (
+            QBXLayerPotentialSource, QBXTargetAssociationFailedException)
+    qbx, _ = QBXLayerPotentialSource(
+            pre_density_discr, fine_order=bdry_ovsmp_quad_order, qbx_order=qbx_order,
+            fmm_order=fmm_order,
+            expansion_disks_in_tree_have_extent=True,
+            ).with_refinement()
+    density_discr = qbx.density_discr
+
+    from pytential.symbolic.pde.cahn_hilliard import CahnHilliardOperator
+    chop = CahnHilliardOperator(
+            # FIXME: Constants?
+            lambda1=1.5,
+            lambda2=1.25,
+            c=1)
+
+    unk = chop.make_unknown("sigma")
+    bound_op = bind(qbx, chop.operator(unk))
+
+    # {{{ fix rhs and solve
+
+    nodes = density_discr.nodes().with_queue(queue)
+
+    def g(xvec):
+        x, y = xvec
+        return cl.clmath.atan2(y, x)
+
+    bc = sym.make_obj_array([
+        # FIXME: Realistic BC
+        g(nodes),
+        -g(nodes),
+        ])
+
+    from pytential.solve import gmres
+    gmres_result = gmres(
+            bound_op.scipy_op(queue, "sigma", dtype=np.complex128),
+            bc, tol=1e-8, progress=True,
+            stall_iterations=0,
+            hard_failure=True)
+
+    # }}}
+
+    # {{{ postprocess/visualize
+
+    sigma = gmres_result.solution
+
+    from sumpy.visualization import FieldPlotter
+    fplot = FieldPlotter(np.zeros(2), extent=5, npoints=500)
+
+    targets = cl.array.to_device(queue, fplot.points)
+
+    qbx_stick_out = qbx.copy(target_stick_out_factor=0.05)
+
+    indicator_qbx = qbx_stick_out.copy(qbx_order=2)
+
+    from sumpy.kernel import LaplaceKernel
+    ones_density = density_discr.zeros(queue)
+    ones_density.fill(1)
+    indicator = bind(
+            (indicator_qbx, PointsTarget(targets)),
+            sym.D(LaplaceKernel(2), sym.var("sigma")))(
+            queue, sigma=ones_density).get()
+
+    try:
+        fld_in_vol = bind(
+                (qbx_stick_out, PointsTarget(targets)),
+                chop.representation(unk))(queue, sigma=sigma).get()
+    except QBXTargetAssociationFailedException as e:
+        fplot.write_vtk_file(
+                "failed-targets.vts",
+                [
+                    ("failed", e.failed_target_flags.get(queue))
+                    ]
+                )
+        raise
+
+    #fplot.show_scalar_in_mayavi(fld_in_vol.real, max_val=5)
+    fplot.write_vtk_file(
+            "potential.vts",
+            [
+                ("potential", fld_in_vol),
+                ("indicator", indicator),
+                ]
+            )
+
+    # }}}
+
+
+if __name__ == "__main__":
+    main()
diff --git a/examples/qbx-tangential-deriv-jump.py b/examples/qbx-tangential-deriv-jump.py
index a2d9aee155d050e3c19bd253b051d8767a21e77a..6af8bd6805884ea97df69fd703fdeedc6dc1b843 100644
--- a/examples/qbx-tangential-deriv-jump.py
+++ b/examples/qbx-tangential-deriv-jump.py
@@ -1,8 +1,8 @@
 import pyopencl as cl
 import numpy as np
-import numpy.linalg as la
+import numpy.linalg as la  # noqa: F401
 
-from pytential import bind, sym, norm
+from pytential import bind, sym, norm  # noqa: F401
 
 
 def main():
@@ -38,15 +38,15 @@ def main():
     sig_sym = sym.var("sig")
     knl = LaplaceKernel(2)
     op = join_fields(
-            sym.tangential_derivative(
-                sym.D(knl, sig_sym, qbx_forced_limit=+1)).a.as_scalar(),
-            sym.tangential_derivative(
-                sym.D(knl, sig_sym, qbx_forced_limit=-1)).a.as_scalar(),
+            sym.tangential_derivative(mesh.ambient_dim,
+                sym.D(knl, sig_sym, qbx_forced_limit=+1)).as_scalar(),
+            sym.tangential_derivative(mesh.ambient_dim,
+                sym.D(knl, sig_sym, qbx_forced_limit=-1)).as_scalar(),
             )
 
     nodes = density_discr.nodes().with_queue(queue)
     angle = cl.clmath.atan2(nodes[1], nodes[0])
-    n = 30
+    n = 10
     sig = cl.clmath.sin(n*angle)
     dt_sig = n*cl.clmath.cos(n*angle)
 
diff --git a/pytential/__init__.py b/pytential/__init__.py
index 98e62c7164105a4c8cb0d1225706ad666ce233d1..7a4e1f4ec91fed3ae83e72396a9e703271a5908b 100644
--- a/pytential/__init__.py
+++ b/pytential/__init__.py
@@ -48,7 +48,14 @@ def _set_up_logging_from_environment():
             set_up_logging(pytential_log_var.split(":"), level=level)
 
 
+def _set_up_errors():
+    import warnings
+    from pytential.qbx.refinement import RefinerNotConvergedWarning
+    warnings.filterwarnings("error", category=RefinerNotConvergedWarning)
+
+
 _set_up_logging_from_environment()
+_set_up_errors()
 
 
 @memoize_on_first_arg
diff --git a/pytential/muller.py b/pytential/muller.py
index 22fe5458e895236e3ec0fcc6ab7634290e0a84ed..5fc6a82378af0e8c86ac5858187014399cf8c42d 100644
--- a/pytential/muller.py
+++ b/pytential/muller.py
@@ -25,7 +25,7 @@ THE SOFTWARE.
 import numpy as np
 
 
-def muller_deflate(f, n, maxiter=100, eps=1e-14):
+def muller_deflate(f, n, maxiter=100, eps=1e-14, z_start=None):
     """
     :arg n: number of zeros sought
     :returns: (roots, niter) - roots of the given function; number of
@@ -52,7 +52,7 @@ def muller_deflate(f, n, maxiter=100, eps=1e-14):
         niter.append(niter0)
 
         while (np.isnan(roots[i]) or niter[i] == maxiter) and miter < 50:
-            roots0, niter0 = muller(f_deflated, maxiter, eps)
+            roots0, niter0 = muller(f_deflated, maxiter, eps, z_start=z_start)
             roots[i] = roots0
             niter[i] = niter0
             miter = miter+1
diff --git a/pytential/qbx/__init__.py b/pytential/qbx/__init__.py
index a18196f401ce355cd5c42f2df0416cf5bc08eb88..4ecf5253e6c0f62f3d0717d7952932f146fc899f 100644
--- a/pytential/qbx/__init__.py
+++ b/pytential/qbx/__init__.py
@@ -25,11 +25,9 @@ THE SOFTWARE.
 
 import six
 
-import loopy as lp
 import numpy as np
 from pytools import memoize_method
 from meshmode.discretization import Discretization
-from meshmode.discretization.poly_element import QuadratureSimplexGroupFactory
 from pytential.qbx.target_assoc import QBXTargetAssociationFailedException
 from pytential.source import PotentialSource
 
@@ -46,45 +44,6 @@ __doc__ = """
 """
 
 
-# {{{ jump term interface helper
-
-class _JumpTermArgumentProvider(object):
-    def __init__(self, discr, density, ds_direction, side=None):
-        self.discr = discr
-        self.density = density
-        self.ds_direction = ds_direction
-        self.side = side
-
-    @property
-    def normal(self):
-        return self.discr.curve.normals.reshape(2, -1).T
-
-    @property
-    def tangent(self):
-        return self.discr.curve.tangents.reshape(2, -1).T
-
-    @property
-    def src_derivative_dir(self):
-        return self.ds_direction
-
-    @property
-    def mean_curvature(self):
-        return self.discr.curve.curvature.reshape(-1)
-
-    @property
-    def density_0(self):
-        return self.density.reshape(-1)
-
-    @property
-    @memoize_method
-    def density_0_prime(self):
-        diff_mat = self.discr.curve.expansion.get_differentiation_matrix()
-        return (2 * np.dot(diff_mat, self.density.T).T.reshape(-1)
-                / self.discr.curve.speed.reshape(-1))
-
-# }}}
-
-
 class LayerPotentialSource(PotentialSource):
     pass
 
@@ -129,29 +88,37 @@ class QBXLayerPotentialSource(LayerPotentialSource):
     .. attribute :: qbx_order
     .. attribute :: fmm_order
     .. attribute :: cl_context
-    .. automethod :: centers
-    .. automethod :: panel_sizes
     .. automethod :: weights_and_area_elements
     .. automethod :: with_refinement
 
     See :ref:`qbxguts` for some information on the inner workings of this.
     """
+
+    # {{{ constructor / copy
+
     def __init__(self, density_discr, fine_order,
             qbx_order=None,
             fmm_order=None,
             fmm_level_to_order=None,
             target_stick_out_factor=1e-10,
+            base_resampler=None,
 
             # begin undocumented arguments
             # FIXME default debug=False once everything works
             debug=True,
             refined_for_global_qbx=False,
+            expansion_disks_in_tree_have_extent=False,
             performance_data_file=None):
         """
         :arg fine_order: The total degree to which the (upsampled)
-            underlying quadrature is exact.
+             underlying quadrature is exact.
+        :arg base_resampler: A connection used for resampling from
+             *density_discr* the fine density discretization.  It is assumed
+             that the fine density discretization given by
+             *base_resampler.to_discr* is *not* already upsampled. May
+             be *None*.
         :arg fmm_order: `False` for direct calculation. ``None`` will set
-            a reasonable(-ish?) default.
+             a reasonable(-ish?) default.
         """
 
         if fmm_level_to_order is None:
@@ -174,8 +141,13 @@ class QBXLayerPotentialSource(LayerPotentialSource):
         self.fmm_level_to_order = fmm_level_to_order
         self.target_stick_out_factor = target_stick_out_factor
 
+        # Default values are lazily provided if these are None
+        self._base_resampler = base_resampler
+
         self.debug = debug
         self.refined_for_global_qbx = refined_for_global_qbx
+        self.expansion_disks_in_tree_have_extent = \
+                expansion_disks_in_tree_have_extent
         self.performance_data_file = performance_data_file
 
     def copy(
@@ -185,6 +157,7 @@ class QBXLayerPotentialSource(LayerPotentialSource):
             qbx_order=None,
             fmm_level_to_order=None,
             target_stick_out_factor=None,
+            base_resampler=None,
 
             debug=None,
             refined_for_global_qbx=None,
@@ -199,69 +172,98 @@ class QBXLayerPotentialSource(LayerPotentialSource):
                 fmm_level_to_order=(
                     fmm_level_to_order or self.fmm_level_to_order),
                 target_stick_out_factor=(
-                    target_stick_out_factor or self.target_stick_out_factor),
+                    target_stick_out_factor
+                    if target_stick_out_factor is not None
+                    else self.target_stick_out_factor),
+                base_resampler=base_resampler or self._base_resampler,
 
                 debug=(
                     debug if debug is not None else self.debug),
                 refined_for_global_qbx=(
                     refined_for_global_qbx if refined_for_global_qbx is not None
                     else self.refined_for_global_qbx),
+                expansion_disks_in_tree_have_extent=(
+                    self.expansion_disks_in_tree_have_extent),
                 performance_data_file=self.performance_data_file)
 
+    # }}}
+
+    @property
+    def base_fine_density_discr(self):
+        """The refined, interpolation-focused density discretization (no oversampling).
+        """
+        # FIXME: Maybe rename refined_interp_density_discr
+        return (self._base_resampler.to_discr
+                if self._base_resampler is not None
+                else self.density_discr)
+
     @property
     @memoize_method
     def fine_density_discr(self):
+        """The refined, quadrature-focused density discretization (with upsampling).
+        """
+        # FIXME: Maybe rename refined_quad_density_discr
+        from meshmode.discretization.poly_element import (
+                QuadratureSimplexGroupFactory)
+
         return Discretization(
-            self.density_discr.cl_context, self.density_discr.mesh,
-            QuadratureSimplexGroupFactory(self.fine_order), self.real_dtype)
+            self.density_discr.cl_context, self.base_fine_density_discr.mesh,
+            QuadratureSimplexGroupFactory(self.fine_order),
+            self.real_dtype)
 
     @property
     @memoize_method
     def resampler(self):
-        from meshmode.discretization.connection import make_same_mesh_connection
-        return make_same_mesh_connection(
-                self.fine_density_discr, self.density_discr)
-
-    def el_view(self, discr, group_nr, global_array):
-        """Return a view of *global_array* of shape
-        ``(..., discr.groups[group_nr].nelements)``
-        where *global_array* is of shape ``(..., nelements)``,
-        where *nelements* is the global (per-discretization) node count.
-        """
+        from meshmode.discretization.connection import (
+            make_same_mesh_connection, ChainedDiscretizationConnection)
 
-        group = discr.groups[group_nr]
-        el_nr_base = sum(group.nelements for group in discr.groups[:group_nr])
+        conn = make_same_mesh_connection(
+                self.fine_density_discr, self.base_fine_density_discr)
 
-        return global_array[
-            ..., el_nr_base:el_nr_base + group.nelements] \
-            .reshape(
-                global_array.shape[:-1]
-                + (group.nelements,))
+        if self._base_resampler is not None:
+            return ChainedDiscretizationConnection([self._base_resampler, conn])
+
+        return conn
+
+    @property
+    @memoize_method
+    def refiner_code_container(self):
+        from pytential.qbx.refinement import RefinerCodeContainer
+        return RefinerCodeContainer(self.cl_context)
 
     @memoize_method
-    def with_refinement(self, target_order=None, maxiter=3):
+    def with_refinement(self, target_order=None, kernel_length_scale=None,
+            maxiter=10):
         """
         :returns: a tuple ``(lpot_src, cnx)``, where ``lpot_src`` is a
             :class:`QBXLayerPotentialSource` and ``cnx`` is a
             :class:`meshmode.discretization.connection.DiscretizationConnection`
             from the originally given to the refined geometry.
         """
-        from pytential.qbx.refinement import QBXLayerPotentialSourceRefiner
-        refiner = QBXLayerPotentialSourceRefiner(self.cl_context)
+        from pytential.qbx.refinement import refine_for_global_qbx
+
         from meshmode.discretization.poly_element import (
-            InterpolatoryQuadratureSimplexGroupFactory)
+                InterpolatoryQuadratureSimplexGroupFactory,
+                QuadratureSimplexGroupFactory)
+
         if target_order is None:
             target_order = self.density_discr.groups[0].order
-        lpot, connection = refiner(self,
+
+        lpot, connection = refine_for_global_qbx(
+                self,
+                self.refiner_code_container,
                 InterpolatoryQuadratureSimplexGroupFactory(target_order),
+                QuadratureSimplexGroupFactory(self.fine_order),
+                kernel_length_scale=kernel_length_scale,
                 maxiter=maxiter)
+
         return lpot, connection
 
     @property
     @memoize_method
     def h_max(self):
         with cl.CommandQueue(self.cl_context) as queue:
-            panel_sizes = self.panel_sizes("npanels").with_queue(queue)
+            panel_sizes = self._panel_sizes("npanels").with_queue(queue)
             return np.asscalar(cl.array.max(panel_sizes).get())
 
     @property
@@ -284,106 +286,72 @@ class QBXLayerPotentialSource(LayerPotentialSource):
     def complex_dtype(self):
         return self.density_discr.complex_dtype
 
-    @memoize_method
-    def panel_centers_of_mass(self):
-        knl = lp.make_kernel(
-            """{[dim,k,i]:
-                0<=dim<ndims and
-                0<=k<nelements and
-                0<=i<nunit_nodes}""",
-            """
-                panels[dim, k] = sum(i, nodes[dim, k, i])/nunit_nodes
-                """,
-            default_offset=lp.auto, name="find_panel_centers_of_mass")
-
-        knl = lp.fix_parameters(knl, ndims=self.ambient_dim)
-
-        knl = lp.split_iname(knl, "k", 128, inner_tag="l.0", outer_tag="g.0")
-        knl = lp.tag_inames(knl, dict(dim="ilp"))
+    # {{{ internal API
 
-        with cl.CommandQueue(self.cl_context) as queue:
-            mesh = self.density_discr.mesh
-            panels = cl.array.empty(queue, (mesh.ambient_dim, mesh.nelements),
-                                    dtype=self.density_discr.real_dtype)
-            for group_nr, group in enumerate(self.density_discr.groups):
-                _, (result,) = knl(queue,
-                    nelements=group.nelements,
-                    nunit_nodes=group.nunit_nodes,
-                    nodes=group.view(self.density_discr.nodes()),
-                    panels=self.el_view(self.density_discr, group_nr, panels))
-            panels.finish()
-            panels = panels.with_queue(None)
-            return tuple(panels[d, :] for d in range(mesh.ambient_dim))
+    @memoize_method
+    def _panel_centers_of_mass(self):
+        import pytential.qbx.utils as utils
+        return utils.element_centers_of_mass(self.density_discr)
 
     @memoize_method
-    def panel_sizes(self, last_dim_length="nsources"):
-        assert last_dim_length in ("nsources", "ncenters", "npanels")
-        # To get the panel size this does the equivalent of (∫ 1 ds)**(1/dim).
-        # FIXME: Kernel optimizations
+    def _fine_panel_centers_of_mass(self):
+        import pytential.qbx.utils as utils
+        return utils.element_centers_of_mass(self.base_fine_density_discr)
 
-        discr = self.density_discr
+    @memoize_method
+    def _expansion_radii(self, last_dim_length):
+        if last_dim_length == "npanels":
+            # FIXME: Make this an error
 
-        if last_dim_length == "nsources" or last_dim_length == "ncenters":
-            knl = lp.make_kernel(
-                "{[i,j,k]: 0<=i<nelements and 0<=j,k<nunit_nodes}",
-                "panel_sizes[i,j] = sum(k, ds[i,k])**(1/dim)",
-                name="compute_size")
+            from warnings import warn
+            warn("Passing 'npanels' as last_dim_length to _expansion_radii is "
+                    "deprecated. Expansion radii should be allowed to vary "
+                    "within a panel.", stacklevel=3)
 
-            def panel_size_view(discr, group_nr):
-                return discr.groups[group_nr].view
+        with cl.CommandQueue(self.cl_context) as queue:
+                return (self._panel_sizes(last_dim_length).with_queue(queue) * 0.5
+                        ).with_queue(None)
 
-        elif last_dim_length == "npanels":
-            knl = lp.make_kernel(
-                "{[i,j]: 0<=i<nelements and 0<=j<nunit_nodes}",
-                "panel_sizes[i] = sum(j, ds[i,j])**(1/dim)",
-                name="compute_size")
-            from functools import partial
+    # _expansion_radii should not be needed for the fine discretization
 
-            def panel_size_view(discr, group_nr):
-                return partial(self.el_view, discr, group_nr)
+    @memoize_method
+    def _close_target_tunnel_radius(self, last_dim_length):
+        with cl.CommandQueue(self.cl_context) as queue:
+                return (self._panel_sizes(last_dim_length).with_queue(queue) * 0.5
+                        ).with_queue(None)
 
-        else:
-            raise ValueError("unknown dim length specified")
+    @memoize_method
+    def _panel_sizes(self, last_dim_length="npanels"):
+        import pytential.qbx.utils as utils
+        return utils.panel_sizes(self.density_discr, last_dim_length)
 
-        knl = lp.fix_parameters(knl, dim=self.dim)
+    @memoize_method
+    def _fine_panel_sizes(self, last_dim_length="npanels"):
+        if last_dim_length != "npanels":
+            raise NotImplementedError()
 
-        with cl.CommandQueue(self.cl_context) as queue:
-            from pytential import bind, sym
-            ds = bind(
-                    discr,
-                    sym.area_element(ambient_dim=discr.ambient_dim, dim=discr.dim)
-                    * sym.QWeight()
-                    )(queue)
-            panel_sizes = cl.array.empty(
-                queue, discr.nnodes
-                if last_dim_length in ("nsources", "ncenters")
-                else discr.mesh.nelements, discr.real_dtype)
-            for group_nr, group in enumerate(discr.groups):
-                _, (result,) = knl(queue,
-                    nelements=group.nelements,
-                    nunit_nodes=group.nunit_nodes,
-                    ds=group.view(ds),
-                    panel_sizes=panel_size_view(
-                        discr, group_nr)(panel_sizes))
-            panel_sizes.finish()
-            if last_dim_length == "ncenters":
-                from pytential.qbx.utils import get_interleaver_kernel
-                knl = get_interleaver_kernel(discr.real_dtype)
-                _, (panel_sizes,) = knl(queue, dstlen=2*discr.nnodes,
-                                        src1=panel_sizes, src2=panel_sizes)
-            return panel_sizes.with_queue(None)
+        import pytential.qbx.utils as utils
+        return utils.panel_sizes(self.base_fine_density_discr, last_dim_length)
 
     @memoize_method
-    def centers(self, sign):
-        adim = self.density_discr.ambient_dim
-        dim = self.density_discr.dim
+    def qbx_fmm_geometry_data(self, target_discrs_and_qbx_sides):
+        """
+        :arg target_discrs_and_qbx_sides:
+            a tuple of *(discr, qbx_forced_limit)*
+            tuples, where *discr* is a
+            :class:`meshmode.discretization.Discretization`
+            or
+            :class:`pytential.target.TargetBase`
+            instance
+        """
+        from pytential.qbx.geometry import QBXFMMGeometryData
 
-        from pytential import sym, bind
-        with cl.CommandQueue(self.cl_context) as queue:
-            nodes = bind(self.density_discr, sym.nodes(adim))(queue)
-            normals = bind(self.density_discr, sym.normal(adim, dim=dim))(queue)
-            panel_sizes = self.panel_sizes().with_queue(queue)
-            return (nodes + normals * sign * panel_sizes / 2).as_vector(np.object)
+        return QBXFMMGeometryData(self.qbx_fmm_code_getter,
+                self, target_discrs_and_qbx_sides,
+                target_stick_out_factor=self.target_stick_out_factor,
+                debug=self.debug)
+
+    # }}}
 
     @memoize_method
     def weights_and_area_elements(self):
@@ -404,6 +372,8 @@ class QBXLayerPotentialSource(LayerPotentialSource):
 
             return (area_element.with_queue(queue)*qweight).with_queue(None)
 
+    # {{{ helpers for symbolic operator processing
+
     def preprocess_optemplate(self, name, discretizations, expr):
         """
         :arg name: The symbolic name for *self*, which the preprocessor
@@ -421,6 +391,10 @@ class QBXLayerPotentialSource(LayerPotentialSource):
 
         return result
 
+    # }}}
+
+    # {{{ internal functionality for execution
+
     def exec_compute_potential_insn(self, queue, insn, bound_expr, evaluate):
         from pytools.obj_array import with_object_array_or_scalar
         from functools import partial
@@ -450,24 +424,6 @@ class QBXLayerPotentialSource(LayerPotentialSource):
         return QBXFMMGeometryCodeGetter(self.cl_context, self.ambient_dim,
                 debug=self.debug)
 
-    @memoize_method
-    def qbx_fmm_geometry_data(self, target_discrs_and_qbx_sides):
-        """
-        :arg target_discrs_and_qbx_sides:
-            a tuple of *(discr, qbx_forced_limit)*
-            tuples, where *discr* is a
-            :class:`meshmode.discretization.Discretization`
-            or
-            :class:`pytential.target.TargetBase`
-            instance
-        """
-        from pytential.qbx.geometry import QBXFMMGeometryData
-
-        return QBXFMMGeometryData(self.qbx_fmm_code_getter,
-                self, target_discrs_and_qbx_sides,
-                target_stick_out_factor=self.target_stick_out_factor,
-                debug=self.debug)
-
     # {{{ fmm-based execution
 
     @memoize_method
@@ -591,7 +547,7 @@ class QBXLayerPotentialSource(LayerPotentialSource):
 
         # }}}
 
-        if len(geo_data.global_qbx_centers()) != geo_data.center_info().ncenters:
+        if len(geo_data.global_qbx_centers()) != geo_data.ncenters:
             raise NotImplementedError("geometry has centers requiring local QBX")
 
         from pytential.qbx.geometry import target_state
@@ -709,6 +665,8 @@ class QBXLayerPotentialSource(LayerPotentialSource):
         strengths = (evaluate(insn.density).with_queue(queue)
                 * self.weights_and_area_elements())
 
+        import pytential.qbx.utils as utils
+
         # FIXME: Do this all at once
         result = []
         for o in insn.outputs:
@@ -724,7 +682,7 @@ class QBXLayerPotentialSource(LayerPotentialSource):
                 evt, output_for_each_kernel = lpot_applier(
                         queue, target_discr.nodes(),
                         self.fine_density_discr.nodes(),
-                        self.centers(o.qbx_forced_limit),
+                        utils.get_centers_on_side(self, o.qbx_forced_limit),
                         [strengths], **kernel_args)
                 result.append((o.name, output_for_each_kernel[o.kernel_index]))
             else:
@@ -748,12 +706,11 @@ class QBXLayerPotentialSource(LayerPotentialSource):
                             (target_discr, qbx_forced_limit),
                         ))
 
-                # center_info is independent of targets
-                center_info = geo_data.center_info()
+                # center-related info is independent of targets
 
                 # First ncenters targets are the centers
                 tgt_to_qbx_center = (
-                        geo_data.user_target_to_center()[center_info.ncenters:]
+                        geo_data.user_target_to_center()[geo_data.ncenters:]
                         .copy(queue=queue))
 
                 qbx_tgt_numberer = self.get_qbx_target_numberer(
@@ -786,7 +743,7 @@ class QBXLayerPotentialSource(LayerPotentialSource):
                             queue,
                             targets=target_discr.nodes(),
                             sources=self.fine_density_discr.nodes(),
-                            centers=center_info.centers,
+                            centers=geo_data.centers(),
                             strengths=[strengths],
                             qbx_tgt_numbers=qbx_tgt_numbers,
                             qbx_center_numbers=qbx_center_numbers,
@@ -798,6 +755,8 @@ class QBXLayerPotentialSource(LayerPotentialSource):
 
     # }}}
 
+    # }}}
+
 # }}}
 
 
diff --git a/pytential/qbx/fmm.py b/pytential/qbx/fmm.py
index e3508b7089d2875c12a77079e763201300b5a30a..426de45d3789a02fd392fe5fadc92ab286e72b33 100644
--- a/pytential/qbx/fmm.py
+++ b/pytential/qbx/fmm.py
@@ -153,7 +153,7 @@ QBXFMMGeometryData.non_qbx_box_target_lists`),
 
         return cl.array.zeros(
                     self.queue,
-                    (self.geo_data.center_info().ncenters,
+                    (self.geo_data.ncenters,
                         len(qbx_l_expn)),
                     dtype=self.dtype)
 
@@ -212,7 +212,7 @@ QBXFMMGeometryData.non_qbx_box_target_lists`),
                 self.queue,
                 global_qbx_centers=geo_data.global_qbx_centers(),
                 qbx_center_to_target_box=geo_data.qbx_center_to_target_box(),
-                qbx_centers=geo_data.center_info().centers,
+                qbx_centers=geo_data.centers(),
 
                 source_box_starts=starts,
                 source_box_lists=lists,
@@ -230,7 +230,7 @@ QBXFMMGeometryData.non_qbx_box_target_lists`),
         qbx_expansions = self.qbx_local_expansion_zeros()
 
         geo_data = self.geo_data
-        if geo_data.center_info().ncenters == 0:
+        if geo_data.ncenters == 0:
             return qbx_expansions
 
         traversal = geo_data.traversal()
@@ -249,7 +249,7 @@ QBXFMMGeometryData.non_qbx_box_target_lists`),
                     qbx_center_to_target_box=geo_data.qbx_center_to_target_box(),
 
                     centers=self.tree.box_centers,
-                    qbx_centers=geo_data.center_info().centers,
+                    qbx_centers=geo_data.centers(),
 
                     src_expansions=source_mpoles_view,
                     src_base_ibox=source_level_start_ibox,
@@ -273,7 +273,7 @@ QBXFMMGeometryData.non_qbx_box_target_lists`),
         qbx_expansions = self.qbx_local_expansion_zeros()
 
         geo_data = self.geo_data
-        if geo_data.center_info().ncenters == 0:
+        if geo_data.ncenters == 0:
             return qbx_expansions
         trav = geo_data.traversal()
 
@@ -294,7 +294,7 @@ QBXFMMGeometryData.non_qbx_box_target_lists`),
                     target_base_ibox=target_level_start_ibox,
 
                     centers=self.tree.box_centers,
-                    qbx_centers=geo_data.center_info().centers,
+                    qbx_centers=geo_data.centers(),
 
                     expansions=target_locals_view,
                     qbx_expansions=qbx_expansions,
@@ -322,7 +322,7 @@ QBXFMMGeometryData.non_qbx_box_target_lists`),
         qbxl2p = self.code.qbxl2p(self.qbx_order)
 
         evt, pot_res = qbxl2p(self.queue,
-                qbx_centers=geo_data.center_info().centers,
+                qbx_centers=geo_data.centers(),
                 global_qbx_centers=geo_data.global_qbx_centers(),
 
                 center_to_targets_starts=ctt.starts,
@@ -369,7 +369,7 @@ def drive_fmm(expansion_wrangler, src_weights):
     # Interface guidelines: Attributes of the tree are assumed to be known
     # to the expansion wrangler and should not be passed.
 
-    logger.debug("start qbx fmm")
+    logger.info("start qbx fmm")
 
     logger.debug("reorder source weights")
     src_weights = wrangler.reorder_sources(src_weights)
@@ -522,7 +522,7 @@ def drive_fmm(expansion_wrangler, src_weights):
 
     # }}}
 
-    logger.debug("qbx fmm complete")
+    logger.info("qbx fmm complete")
 
     return result
 
@@ -652,7 +652,7 @@ def write_performance_model(outf, geo_data):
     qbx_center_to_target_box = geo_data.qbx_center_to_target_box()
     center_to_targets_starts = geo_data.center_to_tree_targets().starts
 
-    ncenters = geo_data.center_info().ncenters
+    ncenters = geo_data.ncenters
 
     outf.write("ncenters = {cost}\n"
             .format(cost=ncenters))
diff --git a/pytential/qbx/geometry.py b/pytential/qbx/geometry.py
index ce93e851d61741af3951096d3f8e131c76b221fc..f2669274bcd018cbc33f5bda5dbd338b173c0446 100644
--- a/pytential/qbx/geometry.py
+++ b/pytential/qbx/geometry.py
@@ -118,72 +118,6 @@ class QBXFMMGeometryCodeGetter(object):
         self.ambient_dim = ambient_dim
         self.debug = debug
 
-    @property
-    @memoize_method
-    def pick_expansion_centers(self):
-        knl = lp.make_kernel(
-            """{[dim,k,i]:
-                0<=dim<ndims and
-                0<=k<nelements and
-                0<=i<nout_nodes}""",
-            """
-                centers[dim, k, i] = all_centers[dim, k, kept_center_indices[i]]
-                radii[k, i] = all_radii[k, kept_center_indices[i]]
-                """,
-            [
-                lp.GlobalArg("all_centers", None,
-                    shape="ndims,nelements,nunit_nodes"),
-                lp.GlobalArg("all_radii", None, shape="nelements,nunit_nodes"),
-                lp.ValueArg("nunit_nodes", np.int32),
-                "..."
-                ],
-            default_offset=lp.auto,
-            name="center_pick")
-
-        knl = lp.fix_parameters(knl, ndims=self.ambient_dim)
-
-        knl = lp.tag_array_axes(knl, "centers,all_centers", "sep, C, C")
-
-        knl = lp.split_iname(knl, "i", 16, inner_tag="l.0")
-        return lp.tag_inames(knl, dict(k="g.0", dim="ilp"))
-
-    @property
-    @memoize_method
-    def find_element_centers(self):
-        knl = lp.make_kernel(
-            """{[dim,k,i]:
-                0<=dim<ndims and
-                0<=k<nelements and
-                0<=i<nunit_nodes}""",
-            """
-                el_centers[dim, k] = sum(i, nodes[dim, k, i])/nunit_nodes
-                """,
-            default_offset=lp.auto, name="find_element_centers")
-
-        knl = lp.fix_parameters(knl, ndims=self.ambient_dim)
-
-        knl = lp.split_iname(knl, "k", 128, inner_tag="l.0", outer_tag="g.0")
-        return lp.tag_inames(knl, dict(dim="ilp"))
-
-    @property
-    @memoize_method
-    def find_element_radii(self):
-        knl = lp.make_kernel(
-            """{[dim,k,i]:
-                0<=dim<ndims and
-                0<=k<nelements and
-                0<=i<nunit_nodes}""",
-            """
-                el_radii[k] = max(dim, max(i, \
-                    fabs(nodes[dim, k, i] - el_centers[dim, k])))
-                """,
-            default_offset=lp.auto, name="find_element_radii")
-
-        knl = lp.fix_parameters(knl, ndims=self.ambient_dim)
-
-        knl = lp.split_iname(knl, "k", 128, inner_tag="l.0", outer_tag="g.0")
-        return lp.tag_inames(knl, dict(dim="unr"))
-
     @memoize_method
     def copy_targets_kernel(self):
         knl = lp.make_kernel(
@@ -236,8 +170,10 @@ class QBXFMMGeometryCodeGetter(object):
 
                     # This write is race-free because each center only belongs
                     # to one box.
-                    qbx_center_to_target_box[itarget_user] = \
-                            box_to_target_box[ibox] {id=tgt_write,if=in_bounds}
+                    if in_bounds
+                        qbx_center_to_target_box[itarget_user] = \
+                                box_to_target_box[ibox] {id=tgt_write}
+                    end
                 end
             end
             """,
@@ -263,104 +199,6 @@ class QBXFMMGeometryCodeGetter(object):
         from boxtree.area_query import LeavesToBallsLookupBuilder
         return LeavesToBallsLookupBuilder(self.cl_context)
 
-    # {{{ check if a center may be used with global QBX
-
-    @memoize_method
-    def qbx_center_for_global_tester(self,
-            coord_dtype, box_id_dtype, particle_id_dtype):
-        from pyopencl.elementwise import ElementwiseTemplate
-        return ElementwiseTemplate(
-            arguments="""//CL:mako//
-                /* input */
-                %for iaxis in range(ambient_dim):
-                    coord_t *center_${iaxis},  /* [ncenters] */
-                %endfor
-                coord_t *radii,  /* [ncenters] */
-                box_id_t *qbx_center_to_target_box, /* [ncenters] */
-
-                box_id_t *neighbor_source_boxes_starts,
-                box_id_t *neighbor_source_boxes_lists,
-                particle_id_t *box_point_source_starts,
-                particle_id_t *box_point_source_counts_cumul,
-                %for iaxis in range(ambient_dim):
-                    coord_t *point_sources_${iaxis},
-                %endfor
-
-                /* output */
-                char *center_may_use_global_qbx,
-                """,
-            operation=r"""//CL:mako//
-                particle_id_t icenter = i;
-
-                %for iaxis in range(ambient_dim):
-                    coord_t my_center_${iaxis} = center_${iaxis}[icenter];
-                %endfor
-                coord_t radius = radii[icenter];
-
-                // {{{ see if there are sources close enough to require local QBX
-
-                bool found_too_close = false;
-
-                coord_t radius_squared = radius * radius;
-
-                box_id_t itgt_box = qbx_center_to_target_box[icenter];
-
-                box_id_t nb_src_start = neighbor_source_boxes_starts[itgt_box];
-                box_id_t nb_src_stop = neighbor_source_boxes_starts[itgt_box+1];
-
-                for (
-                        box_id_t ibox_list = nb_src_start;
-                        ibox_list < nb_src_stop && !found_too_close;
-                        ++ibox_list)
-                {
-                    box_id_t neighbor_box_id =
-                        neighbor_source_boxes_lists[ibox_list];
-
-                    box_id_t bps_start = box_point_source_starts[neighbor_box_id];
-                    box_id_t bps_stop =
-                        bps_start + box_point_source_counts_cumul[neighbor_box_id];
-                    for (
-                            box_id_t ipsrc = bps_start;
-                            ipsrc < bps_stop;
-                            ++ipsrc)
-                    {
-                        %for iaxis in range(ambient_dim):
-                            coord_t psource_${iaxis} = point_sources_${iaxis}[ipsrc];
-                        %endfor
-
-                        coord_t dist_squared = 0
-                        %for iaxis in range(ambient_dim):
-                            + (my_center_${iaxis} - psource_${iaxis})
-                              * (my_center_${iaxis} - psource_${iaxis})
-                        %endfor
-                            ;
-
-                        const coord_t too_close_slack = (1+1e-2)*(1+1e-2);
-                        found_too_close = found_too_close
-                            || (dist_squared*too_close_slack  < radius_squared);
-
-                        if (found_too_close)
-                            break;
-                    }
-                }
-
-                // }}}
-
-                center_may_use_global_qbx[icenter] = !found_too_close;
-                """,
-            name="qbx_test_center_for_global").build(
-                    self.cl_context,
-                    type_aliases=(
-                        ("box_id_t", box_id_dtype),
-                        ("particle_id_t", particle_id_dtype),
-                        ("coord_t", coord_dtype),
-                        ),
-                    var_values=(
-                        ("ambient_dim", self.ambient_dim),
-                        ))
-
-    # }}}
-
     @property
     @memoize_method
     def key_value_sort(self):
@@ -379,7 +217,10 @@ class QBXFMMGeometryCodeGetter(object):
                     VectorArg(particle_id_dtype, "filtered_target_id"),
                     VectorArg(particle_id_dtype, "count"),
                     ],
+
+                # "Does this target have a QBX center?"
                 input_expr="(target_to_center[i] >= 0) ? 1 : 0",
+
                 scan_expr="a+b", neutral="0",
                 output_statement="""
                     if (prev_item != item)
@@ -419,32 +260,6 @@ class TargetInfo(DeviceDataRecord):
     """
 
 
-class CenterInfo(DeviceDataRecord):
-    """Information on location of QBX centers.
-    Returned from :meth:`QBXFMMGeometryData.center_info`.
-
-    .. attribute:: centers
-
-        Shape: ``[dim][ncenters]``
-
-    .. attribute:: sides
-
-        Shape: ``[ncenters]``
-
-        -1 for inside, +1 for outside (relative to normal)
-
-    .. attribute:: radii
-
-        Shape: ``[ncenters]``
-
-    .. attribute:: ncenters
-    """
-
-    @property
-    def ncenters(self):
-        return len(self.radii)
-
-
 class CenterToTargetList(DeviceDataRecord):
     """A lookup table of targets covered by each QBX disk. Indexed by global
     number of QBX center, ``lists[start[i]:start[i+1]]`` indicates numbers
@@ -516,19 +331,22 @@ class QBXFMMGeometryData(object):
 
     .. attribute:: coord_dtype
 
+    .. rubric :: Expansion centers
+
+    .. attribute:: ncenters
+    .. automethod:: centers()
+    .. automethod:: radii()
+
     .. rubric :: Methods
 
-    .. automethod:: center_info()
     .. automethod:: target_info()
     .. automethod:: tree()
     .. automethod:: traversal()
-    .. automethod:: leaf_to_center_lookup
     .. automethod:: qbx_center_to_target_box()
     .. automethod:: global_qbx_flags()
     .. automethod:: global_qbx_centers()
     .. automethod:: user_target_to_center()
     .. automethod:: center_to_tree_targets()
-    .. automethod:: global_qbx_centers_box_target_lists()
     .. automethod:: non_qbx_box_target_lists()
     .. automethod:: plot()
     """
@@ -561,35 +379,36 @@ class QBXFMMGeometryData(object):
     def coord_dtype(self):
         return self.lpot_source.fine_density_discr.nodes().dtype
 
-    # {{{ center info
+    # {{{ centers/radii
 
-    @memoize_method
-    def kept_center_indices(self, el_group):
-        """Return indices of unit nodes (of the target discretization)
-        that will give rise to QBX centers.
-        """
-
-        # FIXME: Be more careful about which nodes to keep
-        return np.arange(0, el_group.nunit_nodes)
+    @property
+    def ncenters(self):
+        return len(self.centers()[0])
 
     @memoize_method
-    def center_info(self):
-        """ Return a :class:`CenterInfo`. |cached|
-        """
+    def centers(self):
+        """ Return an object array of (interleaved) center coordinates.
 
-        lpot_source = self.lpot_source
+        ``coord_t [ambient_dim][ncenters]``
+        """
 
         with cl.CommandQueue(self.cl_context) as queue:
             from pytential.qbx.utils import get_interleaved_centers
-            centers = get_interleaved_centers(queue, lpot_source)
-            sides = cl.array.arange(queue, len(centers[0]), dtype=np.int32)
-            sides = 2 * (sides & 1) - 1
-            radii = lpot_source.panel_sizes("ncenters").with_queue(queue) / 2
+            from pytools.obj_array import make_obj_array
+            return make_obj_array([
+                ccomp.with_queue(None)
+                for ccomp in get_interleaved_centers(queue, self.lpot_source)])
 
-        return CenterInfo(
-                sides=sides,
-                radii=radii,
-                centers=centers).with_queue(None)
+    @memoize_method
+    def expansion_radii(self):
+        """Return an  array of radii associated with the (interleaved)
+        expansion centers.
+
+        ``coord_t [ncenters]``
+        """
+        with cl.CommandQueue(self.cl_context) as queue:
+            from pytential.qbx.utils import get_interleaved_radii
+            return get_interleaved_radii(queue, self.lpot_source)
 
     # }}}
 
@@ -603,9 +422,7 @@ class QBXFMMGeometryData(object):
         lpot_src = self.lpot_source
 
         with cl.CommandQueue(self.cl_context) as queue:
-            center_info = self.center_info()
-
-            ntargets = center_info.ncenters
+            ntargets = self.ncenters
             target_discr_starts = []
 
             for target_discr, qbx_side in self.target_discrs_and_qbx_sides:
@@ -619,8 +436,8 @@ class QBXFMMGeometryData(object):
                     self.coord_dtype)
             code_getter.copy_targets_kernel()(
                     queue,
-                    targets=targets[:, :center_info.ncenters],
-                    points=center_info.centers)
+                    targets=targets[:, :self.ncenters],
+                    points=self.centers())
 
             for start, (target_discr, _) in zip(
                     target_discr_starts, self.target_discrs_and_qbx_sides):
@@ -634,23 +451,6 @@ class QBXFMMGeometryData(object):
                     target_discr_starts=target_discr_starts,
                     ntargets=ntargets).with_queue(None)
 
-    def target_radii(self):
-        """Shape: ``[ntargets]``
-
-        A list of target radii for FMM tree construction. In this list, the QBX
-        centers have the radii determined by :meth:`center_info`, and all other
-        targets have radius zero.
-        """
-
-        tgt_info = self.target_info()
-        center_info = self.center_info()
-
-        with cl.CommandQueue(self.cl_context) as queue:
-            target_radii = cl.array.zeros(queue, tgt_info.ntargets, self.coord_dtype)
-            target_radii[:center_info.ncenters] = center_info.radii
-
-            return target_radii.with_queue(None)
-
     def target_side_preferences(self):
         """Return one big array combining all the data from
         the *side* part of :attr:`TargetInfo.target_discrs_and_qbx_sides`.
@@ -658,12 +458,11 @@ class QBXFMMGeometryData(object):
         Shape: ``[ntargets]``, dtype: int8"""
 
         tgt_info = self.target_info()
-        center_info = self.center_info()
 
         with cl.CommandQueue(self.cl_context) as queue:
             target_side_preferences = cl.array.empty(
                     queue, tgt_info.ntargets, np.int8)
-            target_side_preferences[:center_info.ncenters] = 0
+            target_side_preferences[:self.ncenters] = 0
 
             for tdstart, (target_discr, qbx_side) in \
                     zip(tgt_info.target_discr_starts,
@@ -679,7 +478,7 @@ class QBXFMMGeometryData(object):
 
     @memoize_method
     def tree(self):
-        """Build and return a :class:`boxtree.tree.TreeWithLinkedPointSources`
+        """Build and return a :class:`boxtree.tree.Tree`
         for this source with these targets.
 
         |cached|
@@ -693,6 +492,12 @@ class QBXFMMGeometryData(object):
             nsources = lpot_src.fine_density_discr.nnodes
             nparticles = nsources + target_info.ntargets
 
+            target_radii = None
+            if self.lpot_source.expansion_disks_in_tree_have_extent:
+                target_radii = cl.array.zeros(queue, target_info.ntargets,
+                        self.coord_dtype)
+                target_radii[:self.ncenters] = self.expansion_radii()
+
             refine_weights = cl.array.zeros(queue, nparticles, dtype=np.int32)
             refine_weights[:nsources] = 1
             refine_weights.finish()
@@ -707,13 +512,22 @@ class QBXFMMGeometryData(object):
             # to avoid having too many disks overlap more than one box.
             #
             # FIXME: Should investigate this further.
+
             tree, _ = code_getter.build_tree(queue,
                     particles=lpot_src.fine_density_discr.nodes(),
                     targets=target_info.targets,
+                    target_radii=target_radii,
                     max_leaf_refine_weight=256,
                     refine_weights=refine_weights,
                     debug=self.debug,
-                    kind="adaptive-level-restricted")
+                    stick_out_factor=0,
+                    kind="adaptive")
+
+            if self.debug:
+                tgt_count_2 = cl.array.sum(
+                        tree.box_target_counts_nonchild, queue=queue).get()
+
+                assert (tree.ntargets == tgt_count_2), (tree.ntargets, tgt_count_2)
 
             return tree
 
@@ -731,31 +545,21 @@ class QBXFMMGeometryData(object):
             trav, _ = self.code_getter.build_traversal(queue, self.tree(),
                     debug=self.debug)
 
-            return trav
-
-    def leaf_to_center_lookup(self):
-        """Return a :class:`boxtree.area_query.LeavesToBallsLookup` to look up
-        which which QBX disks overlap each leaf box.
-        """
-
-        center_info = self.center_info()
+            if self.lpot_source.expansion_disks_in_tree_have_extent:
+                trav = trav.merge_close_lists(queue)
 
-        with cl.CommandQueue(self.cl_context) as queue:
-            lbl, _ = self.code_getter.build_leaf_to_ball_lookup(queue,
-                    self.tree(), center_info.centers, center_info.radii)
-            return lbl
+            return trav
 
     @memoize_method
     def qbx_center_to_target_box(self):
-        """Return a lookup table of length :attr:`CenterInfo.ncenters`
-        (see :meth:`center_info`) indicating the target box in which each
+        """Return a lookup table of length :attr:`ncenters`
+        indicating the target box in which each
         QBX disk is located.
 
         |cached|
         """
         tree = self.tree()
         trav = self.traversal()
-        center_info = self.center_info()
 
         qbx_center_to_target_box_lookup = \
                 self.code_getter.qbx_center_to_target_box_lookup(
@@ -782,24 +586,36 @@ class QBXFMMGeometryData(object):
                             queue, len(sorted_target_ids),
                             user_target_from_tree_target.dtype)
 
-            evt, (qbx_center_to_target_box,) = qbx_center_to_target_box_lookup(
+            qbx_center_to_target_box = cl.array.empty(
+                    queue, self.ncenters, tree.box_id_dtype)
+
+            if self.lpot_source.debug:
+                qbx_center_to_target_box.fill(-1)
+
+            evt, _ = qbx_center_to_target_box_lookup(
                     queue,
+                    qbx_center_to_target_box=qbx_center_to_target_box,
                     box_to_target_box=box_to_target_box,
                     box_target_starts=tree.box_target_starts,
                     box_target_counts_nonchild=tree.box_target_counts_nonchild,
                     user_target_from_tree_target=user_target_from_tree_target,
-                    ncenters=center_info.ncenters)
+                    ncenters=self.ncenters)
+
+            if self.lpot_source.debug:
+                assert 0 <= cl.array.min(qbx_center_to_target_box).get()
+                assert (
+                        cl.array.max(qbx_center_to_target_box).get()
+                        < len(trav.target_boxes))
 
             return qbx_center_to_target_box.with_queue(None)
 
     @memoize_method
     def global_qbx_flags(self):
         """Return an array of :class:`numpy.int8` of length
-        :attr:`CenterInfo.ncenters` (see :meth:`center_info`) indicating
-        whether each center can use gloal QBX, i.e. whether a single expansion
-        can mediate interactions from *all* sources to all targets for which it
-        is valid. If global QBX can be used, the center's entry will be 1,
-        otherwise it will be 0.
+        :attr:`ncenters` indicating whether each center can use gloal QBX, i.e.
+        whether a single expansion can mediate interactions from *all* sources
+        to all targets for which it is valid. If global QBX can be used, the
+        center's entry will be 1, otherwise it will be 0.
 
         (If not, local QBX is needed, and the center may only be
         able to mediate some of the interactions to a given target.)
@@ -807,10 +623,8 @@ class QBXFMMGeometryData(object):
         |cached|
         """
 
-        center_info = self.center_info()
-
         with cl.CommandQueue(self.cl_context) as queue:
-            result = cl.array.empty(queue, center_info.ncenters, np.int8)
+            result = cl.array.empty(queue, self.ncenters, np.int8)
             result.fill(1)
 
         return result.with_queue(None)
@@ -829,7 +643,7 @@ class QBXFMMGeometryData(object):
 
             logger.info("find global qbx centers: start")
             result, count, _ = copy_if(
-                    cl.array.arange(queue, self.center_info().ncenters,
+                    cl.array.arange(queue, self.ncenters,
                         tree.particle_id_dtype),
                     "global_qbx_flags[i] != 0",
                     extra_args=[
@@ -857,16 +671,15 @@ class QBXFMMGeometryData(object):
         tgt_assoc = QBXTargetAssociator(self.cl_context)
 
         tgt_info = self.target_info()
-        center_info = self.center_info()
 
         from pytential.target import PointsTarget
 
         with cl.CommandQueue(self.cl_context) as queue:
             target_side_prefs = (self
-                .target_side_preferences()[center_info.ncenters:].get(queue=queue))
+                .target_side_preferences()[self.ncenters:].get(queue=queue))
 
         target_discrs_and_qbx_sides = [(
-                PointsTarget(tgt_info.targets[:, center_info.ncenters:]),
+                PointsTarget(tgt_info.targets[:, self.ncenters:]),
                 target_side_prefs.astype(np.int32))]
 
         # FIXME: try block...
@@ -878,8 +691,8 @@ class QBXFMMGeometryData(object):
 
         with cl.CommandQueue(self.cl_context) as queue:
             result = cl.array.empty(queue, tgt_info.ntargets, tree.particle_id_dtype)
-            result[:center_info.ncenters].fill(target_state.NO_QBX_NEEDED)
-            result[center_info.ncenters:] = tgt_assoc_result.target_to_center
+            result[:self.ncenters].fill(target_state.NO_QBX_NEEDED)
+            result[self.ncenters:] = tgt_assoc_result.target_to_center
 
         return result
 
@@ -891,7 +704,6 @@ class QBXFMMGeometryData(object):
         |cached|
         """
 
-        center_info = self.center_info()
         user_ttc = self.user_target_to_center()
 
         with cl.CommandQueue(self.cl_context) as queue:
@@ -917,7 +729,7 @@ class QBXFMMGeometryData(object):
             center_target_starts, targets_sorted_by_center, _ = \
                     self.code_getter.key_value_sort(queue,
                             filtered_tree_ttc, filtered_target_ids,
-                            center_info.ncenters, tree_ttc.dtype)
+                            self.ncenters, tree_ttc.dtype)
 
             logger.info("build center -> targets lookup table: done")
 
@@ -927,31 +739,6 @@ class QBXFMMGeometryData(object):
 
             return result
 
-    @memoize_method
-    def global_qbx_centers_box_target_lists(self):
-        """Build a list of targets per box consisting only of global QBX centers.
-        Returns a :class:`boxtree.tree.FilteredTargetListsInUserOrder`.
-        (I.e. no new target order is created for these targets, as we expect
-        there to be (relatively) not many of them.)
-
-        |cached|
-        """
-
-        center_info = self.center_info()
-        with cl.CommandQueue(self.cl_context) as queue:
-            logger.info("find global qbx centers box target list: start")
-
-            flags = cl.array.zeros(queue, self.tree().ntargets, np.int8)
-
-            flags[:center_info.ncenters] = self.global_qbx_flags()
-
-            from boxtree.tree import filter_target_lists_in_user_order
-            result = filter_target_lists_in_user_order(queue, self.tree(), flags)
-
-            logger.info("find global qbx centers box target list: done")
-
-            return result.with_queue(None)
-
     @memoize_method
     def non_qbx_box_target_lists(self):
         """Build a list of targets per box that don't need to bother with QBX.
@@ -973,7 +760,7 @@ class QBXFMMGeometryData(object):
 
             # 'flags' is in user order, and should be.
 
-            nqbx_centers = self.center_info().ncenters
+            nqbx_centers = self.ncenters
             flags[:nqbx_centers] = 0
 
             from boxtree.tree import filter_target_lists_in_tree_order
@@ -985,10 +772,14 @@ class QBXFMMGeometryData(object):
 
     # {{{ plotting (for debugging)
 
-    def plot(self):
+    def plot(self, draw_circles=False, draw_center_numbers=False,
+            highlight_centers=None):
         """Plot most of the information contained in a :class:`QBXFMMGeometryData`
         object, for debugging.
 
+        :arg highlight_centers: If not *None*, an object with which the array of
+            centers can be indexed to find the highlighted centers.
+
         .. note::
 
             This only works for two-dimensional geometries.
@@ -1000,8 +791,8 @@ class QBXFMMGeometryData(object):
 
         with cl.CommandQueue(self.cl_context) as queue:
             import matplotlib.pyplot as pt
-            nodes_host = self.lpot_source.fine_density_discr.nodes().get(queue)
-            pt.plot(nodes_host[0], nodes_host[1], "x-")
+            from meshmode.discretization.visualization import draw_curve
+            draw_curve(self.lpot_source.fine_density_discr)
 
             global_flags = self.global_qbx_flags().get(queue=queue)
 
@@ -1012,22 +803,35 @@ class QBXFMMGeometryData(object):
 
             # {{{ draw centers and circles
 
-            center_info = self.center_info()
+            centers = self.centers()
             centers = [
-                    center_info.centers[0].get(queue),
-                    center_info.centers[1].get(queue)]
+                    centers[0].get(queue),
+                    centers[1].get(queue)]
             pt.plot(centers[0][global_flags == 0],
                     centers[1][global_flags == 0], "oc",
                     label="centers needing local qbx")
+
+            if highlight_centers is not None:
+                pt.plot(centers[0][highlight_centers],
+                        centers[1][highlight_centers], "oc",
+                        label="highlighted centers",
+                        markersize=15)
+
             ax = pt.gca()
-            for icenter, (cx, cy, r) in enumerate(zip(
-                    centers[0], centers[1], center_info.radii.get(queue))):
-                ax.add_artist(
-                        pt.Circle((cx, cy), r, fill=False, ls="dotted", lw=1))
-                pt.text(cx, cy,
-                        str(icenter), fontsize=8,
-                        ha="left", va="center",
-                        bbox=dict(facecolor='white', alpha=0.5, lw=0))
+
+            if draw_circles:
+                for icenter, (cx, cy, r) in enumerate(zip(
+                        centers[0], centers[1],
+                        self.expansion_radii().get(queue))):
+                    ax.add_artist(
+                            pt.Circle((cx, cy), r, fill=False, ls="dotted", lw=1))
+
+            if draw_center_numbers:
+                for icenter, (cx, cy, r) in enumerate(zip(centers[0], centers[1])):
+                    pt.text(cx, cy,
+                            str(icenter), fontsize=8,
+                            ha="left", va="center",
+                            bbox=dict(facecolor='white', alpha=0.5, lw=0))
 
             # }}}
 
@@ -1054,9 +858,9 @@ class QBXFMMGeometryData(object):
             tccount = 0
             checked = 0
             for tx, ty, tcenter in zip(
-                    targets[0][center_info.ncenters:],
-                    targets[1][center_info.ncenters:],
-                    ttc[center_info.ncenters:]):
+                    targets[0][self.ncenters:],
+                    targets[1][self.ncenters:],
+                    ttc[self.ncenters:]):
                 checked += 1
                 if tcenter >= 0:
                     tccount += 1
diff --git a/pytential/qbx/refinement.py b/pytential/qbx/refinement.py
index c942b45b64b9a7eb275eea8f965de189b7c3e491..8efd9a8800f614f9d54a5d30285f108c42072ba1 100644
--- a/pytential/qbx/refinement.py
+++ b/pytential/qbx/refinement.py
@@ -3,7 +3,7 @@ from __future__ import division, absolute_import, print_function
 
 __copyright__ = """
 Copyright (C) 2013 Andreas Kloeckner
-Copyright (C) 2016 Matt Wala
+Copyright (C) 2016, 2017 Matt Wala
 """
 
 __license__ = """
@@ -32,98 +32,56 @@ import numpy as np
 import pyopencl as cl
 
 from pytools import memoize_method
-from pytential.qbx.utils import DiscrPlotterMixin
 from boxtree.area_query import AreaQueryElementwiseTemplate
-from pyopencl.elementwise import ElementwiseTemplate
 from boxtree.tools import InlineBinarySearch
 from pytential.qbx.utils import QBX_TREE_C_PREAMBLE, QBX_TREE_MAKO_DEFS
 
-unwrap_args = AreaQueryElementwiseTemplate.unwrap_args
-
 import logging
 logger = logging.getLogger(__name__)
 
-__doc__ = """
-Refinement
-^^^^^^^^^^
-
-.. autoclass:: QBXLayerPotentialSourceRefiner
-"""
-
-# {{{ kernels
-
-TUNNEL_QUERY_DISTANCE_FINDER_TEMPLATE = ElementwiseTemplate(
-    arguments=r"""//CL:mako//
-        /* input */
-        particle_id_t source_offset,
-        particle_id_t panel_offset,
-        int npanels,
-        particle_id_t *panel_to_source_starts,
-        particle_id_t *sorted_target_ids,
-        coord_t *panel_sizes,
-
-        /* output */
-        float *tunnel_query_dists,
 
-        /* input, dim-dependent size */
-        %for ax in AXIS_NAMES[:dimensions]:
-            coord_t *particles_${ax},
-        %endfor
-        """,
-    operation=QBX_TREE_C_PREAMBLE + QBX_TREE_MAKO_DEFS + r"""//CL:mako//
-        /* Find my panel. */
-        particle_id_t panel = bsearch(panel_to_source_starts, npanels + 1, i);
+# max_levels granularity for the stack used by the tree descent code in the
+# area query kernel.
+MAX_LEVELS_INCREMENT = 10
 
-        /* Compute dist(tunnel region, panel center) */
 
-        coord_vec_t center_of_mass;
-        ${load_particle("INDEX_FOR_PANEL_PARTICLE(panel)", "center_of_mass")}
+__doc__ = """
+The refiner takes a layer potential source and refines it until it satisfies
+three global QBX refinement criteria:
 
-        coord_vec_t center;
-        ${load_particle("INDEX_FOR_SOURCE_PARTICLE(i)", "center")}
+   * *Condition 1* (Expansion disk undisturbed by sources)
+      A center must be closest to its own source.
 
-        coord_t panel_size = panel_sizes[panel];
+   * *Condition 2* (Sufficient quadrature sampling from all source panels)
+      The quadrature contribution from each panel is as accurate
+      as from the center's own source panel.
 
-        coord_t max_dist = 0;
+   * *Condition 3* (Panel size bounded based on kernel length scale)
+      The panel size is bounded by a kernel length scale. This
+      applies only to Helmholtz kernels.
 
-        %for ax in AXIS_NAMES[:dimensions]:
-        {
-            max_dist = fmax(max_dist,
-                distance(center_of_mass.${ax}, center.${ax} + panel_size / 2));
-            max_dist = fmax(max_dist,
-                distance(center_of_mass.${ax}, center.${ax} - panel_size / 2));
-        }
-        %endfor
+.. autoclass:: RefinerCodeContainer
+.. autoclass:: RefinerWrangler
+.. autoclass:: RefinerNotConvergedWarning
 
-        // The atomic max operation supports only integer types.
-        // However, max_dist is of a floating point type.
-        // For comparison purposes we reinterpret the bits of max_dist
-        // as an integer. The comparison result is the same as for positive
-        // IEEE floating point numbers, so long as the float/int endianness
-        // matches (fingers crossed).
-        atomic_max(
-            (volatile __global int *)
-                &tunnel_query_dists[panel],
-            as_int((float) max_dist));
-        """,
-    name="find_tunnel_query_distance",
-    preamble=str(InlineBinarySearch("particle_id_t")))
+.. autofunction:: make_empty_refine_flags
+.. autofunction:: refine_for_global_qbx
+"""
 
+# {{{ kernels
 
-# Implements "Algorithm for triggering refinement based on Condition 1"
-#
-# Does not use r_max as we do not use Newton for checking center-panel closeness.
-CENTER_IS_CLOSEST_TO_ORIG_PANEL_REFINER = AreaQueryElementwiseTemplate(
+# Refinement checker for Condition 1.
+EXPANSION_DISK_UNDISTURBED_BY_SOURCES_CHECKER = AreaQueryElementwiseTemplate(
     extra_args=r"""
         /* input */
-        particle_id_t *box_to_panel_starts,
-        particle_id_t *box_to_panel_lists,
+        particle_id_t *box_to_source_starts,
+        particle_id_t *box_to_source_lists,
         particle_id_t *panel_to_source_starts,
         particle_id_t *panel_to_center_starts,
         particle_id_t source_offset,
         particle_id_t center_offset,
         particle_id_t *sorted_target_ids,
-        coord_t *panel_sizes,
+        coord_t *center_danger_zone_radii_by_panel,
         int npanels,
 
         /* output */
@@ -136,39 +94,35 @@ CENTER_IS_CLOSEST_TO_ORIG_PANEL_REFINER = AreaQueryElementwiseTemplate(
         %endfor
         """,
     ball_center_and_radius_expr=QBX_TREE_C_PREAMBLE + QBX_TREE_MAKO_DEFS + r"""
+        /* Find the panel associated with this center. */
         particle_id_t my_panel = bsearch(panel_to_center_starts, npanels + 1, i);
 
         ${load_particle("INDEX_FOR_CENTER_PARTICLE(i)", ball_center)}
-        ${ball_radius} = panel_sizes[my_panel] / 2;
+        ${ball_radius} = center_danger_zone_radii_by_panel[my_panel];
         """,
     leaf_found_op=QBX_TREE_MAKO_DEFS + r"""
-        for (particle_id_t panel_idx = box_to_panel_starts[${leaf_box_id}];
-             panel_idx < box_to_panel_starts[${leaf_box_id} + 1];
-             ++panel_idx)
+        /* Check that each source in the leaf box is sufficiently far from the
+           center; if not, mark the panel for refinement. */
+
+        for (particle_id_t source_idx = box_to_source_starts[${leaf_box_id}];
+             source_idx < box_to_source_starts[${leaf_box_id} + 1];
+             ++source_idx)
         {
-            particle_id_t panel = box_to_panel_lists[panel_idx];
+            particle_id_t source = box_to_source_lists[source_idx];
+            particle_id_t panel = bsearch(
+                panel_to_source_starts, npanels + 1, source);
 
-            // Skip self.
             if (my_panel == panel)
             {
                 continue;
             }
 
-            bool is_close = false;
-
-            for (particle_id_t source = panel_to_source_starts[panel];
-                 source < panel_to_source_starts[panel + 1];
-                 ++source)
-            {
-                coord_vec_t source_coords;
+            coord_vec_t source_coords;
+            ${load_particle("INDEX_FOR_SOURCE_PARTICLE(source)", "source_coords")}
 
-                ${load_particle(
-                    "INDEX_FOR_SOURCE_PARTICLE(source)", "source_coords")}
-
-                is_close |= (
-                    distance(${ball_center}, source_coords)
-                    <= panel_sizes[my_panel] / 2);
-            }
+            bool is_close = (
+                distance(${ball_center}, source_coords)
+                <= center_danger_zone_radii_by_panel[my_panel]);
 
             if (is_close)
             {
@@ -178,27 +132,22 @@ CENTER_IS_CLOSEST_TO_ORIG_PANEL_REFINER = AreaQueryElementwiseTemplate(
             }
         }
         """,
-    name="refine_center_closest_to_orig_panel",
+    name="check_center_closest_to_orig_panel",
     preamble=str(InlineBinarySearch("particle_id_t")))
 
 
-# Implements "Algorithm for triggering refinement based on Condition 2"
-CENTER_IS_FAR_FROM_NONNEIGHBOR_PANEL_REFINER = AreaQueryElementwiseTemplate(
+# Refinement checker for Condition 2.
+SUFFICIENT_SOURCE_QUADRATURE_RESOLUTION_CHECKER = AreaQueryElementwiseTemplate(
     extra_args=r"""
         /* input */
-        particle_id_t *box_to_panel_starts,
-        particle_id_t *box_to_panel_lists,
+        particle_id_t *box_to_center_starts,
+        particle_id_t *box_to_center_lists,
         particle_id_t *panel_to_source_starts,
-        particle_id_t *panel_to_center_starts,
         particle_id_t source_offset,
         particle_id_t center_offset,
-        particle_id_t panel_offset,
         particle_id_t *sorted_target_ids,
-        coord_t *panel_sizes,
-        particle_id_t *panel_adjacency_starts,
-        particle_id_t *panel_adjacency_lists,
+        coord_t *source_danger_zone_radii_by_panel,
         int npanels,
-        coord_t *tunnel_query_dists,
 
         /* output */
         int *panel_refine_flags,
@@ -210,51 +159,29 @@ CENTER_IS_FAR_FROM_NONNEIGHBOR_PANEL_REFINER = AreaQueryElementwiseTemplate(
         %endfor
         """,
     ball_center_and_radius_expr=QBX_TREE_C_PREAMBLE + QBX_TREE_MAKO_DEFS + r"""
-        particle_id_t my_panel = bsearch(panel_to_center_starts, npanels + 1, i);
-        coord_vec_t my_center_coords;
+        /* Find the panel associated with this source. */
+        particle_id_t my_panel = bsearch(panel_to_source_starts, npanels + 1, i);
 
-        ${load_particle("INDEX_FOR_CENTER_PARTICLE(i)", "my_center_coords")}
-        ${load_particle("INDEX_FOR_PANEL_PARTICLE(my_panel)", ball_center)}
-        ${ball_radius} = tunnel_query_dists[my_panel];
+        ${load_particle("INDEX_FOR_SOURCE_PARTICLE(i)", ball_center)}
+        ${ball_radius} = source_danger_zone_radii_by_panel[my_panel];
         """,
     leaf_found_op=QBX_TREE_MAKO_DEFS + r"""
-        for (particle_id_t panel_idx = box_to_panel_starts[${leaf_box_id}];
-             panel_idx < box_to_panel_starts[${leaf_box_id} + 1];
-             ++panel_idx)
-        {
-            particle_id_t panel = box_to_panel_lists[panel_idx];
-
-            bool is_self_or_adjacent = (my_panel == panel);
+        /* Check that each center in the leaf box is sufficiently far from the
+           panel; if not, mark the panel for refinement. */
 
-            for (particle_id_t adj_panel_idx = panel_adjacency_starts[my_panel];
-                 adj_panel_idx < panel_adjacency_starts[my_panel + 1];
-                 ++adj_panel_idx)
-            {
-                is_self_or_adjacent |= (
-                    panel_adjacency_lists[adj_panel_idx] == panel);
-            }
-
-            // Skip self and adjacent panels.
-            if (is_self_or_adjacent)
-            {
-                continue;
-            }
-
-            bool is_close = false;
-
-            for (particle_id_t source = panel_to_source_starts[panel];
-                 source < panel_to_source_starts[panel + 1];
-                 ++source)
-            {
-                coord_vec_t source_coords;
+        for (particle_id_t center_idx = box_to_center_starts[${leaf_box_id}];
+             center_idx < box_to_center_starts[${leaf_box_id} + 1];
+             ++center_idx)
+        {
+            particle_id_t center = box_to_center_lists[center_idx];
 
-                ${load_particle(
-                    "INDEX_FOR_SOURCE_PARTICLE(source)", "source_coords")}
+            coord_vec_t center_coords;
+            ${load_particle(
+                "INDEX_FOR_CENTER_PARTICLE(center)", "center_coords")}
 
-                is_close |= (
-                    distance(my_center_coords, source_coords)
-                    <= panel_sizes[panel] / 2);
-            }
+            bool is_close = (
+                distance(${ball_center}, center_coords)
+                <= source_danger_zone_radii_by_panel[my_panel]);
 
             if (is_close)
             {
@@ -264,123 +191,46 @@ CENTER_IS_FAR_FROM_NONNEIGHBOR_PANEL_REFINER = AreaQueryElementwiseTemplate(
             }
         }
         """,
-    name="refine_center_far_from_nonneighbor_panels",
+    name="check_source_quadrature_resolution",
     preamble=str(InlineBinarySearch("particle_id_t")))
 
 # }}}
 
 
-# {{{ lpot source refiner
+# {{{ code container
 
-class QBXLayerPotentialSourceRefiner(DiscrPlotterMixin):
-    """
-    Driver for refining the QBX source grid. Follows [1]_.
+class RefinerCodeContainer(object):
 
-    .. [1] Rachh, Manas, Andreas Klöckner, and Michael O'Neil. "Fast
-       algorithms for Quadrature by Expansion I: Globally valid expansions."
-
-    .. automethod:: get_refine_flags
-    .. automethod:: __call__
-    """
-
-    def __init__(self, context):
-        self.cl_context = context
-        from pytential.qbx.utils import TreeWithQBXMetadataBuilder
-        self.tree_builder = TreeWithQBXMetadataBuilder(self.cl_context)
-        from boxtree.area_query import PeerListFinder
-        self.peer_list_finder = PeerListFinder(self.cl_context)
-
-    # {{{ kernels
+    def __init__(self, cl_context):
+        self.cl_context = cl_context
 
     @memoize_method
-    def get_tunnel_query_distance_finder(self, dimensions, coord_dtype,
-                                         particle_id_dtype):
-        from pyopencl.tools import dtype_to_ctype
-        from boxtree.tools import AXIS_NAMES
-        logger.info("refiner: building tunnel query distance finder kernel")
-
-        knl = TUNNEL_QUERY_DISTANCE_FINDER_TEMPLATE.build(
+    def expansion_disk_undisturbed_by_sources_checker(
+            self, dimensions, coord_dtype, box_id_dtype, peer_list_idx_dtype,
+            particle_id_dtype, max_levels):
+        return EXPANSION_DISK_UNDISTURBED_BY_SOURCES_CHECKER.generate(
                 self.cl_context,
-                type_aliases=(
-                    ("particle_id_t", particle_id_dtype),
-                    ("coord_t", coord_dtype),
-                    ),
-                var_values=(
-                    ("dimensions", dimensions),
-                    ("AXIS_NAMES", AXIS_NAMES),
-                    ("coord_dtype", coord_dtype),
-                    ("dtype_to_ctype", dtype_to_ctype),
-                    ("vec_types", tuple(cl.array.vec.types.items())),
-                    ))
-
-        logger.info("refiner: done building tunnel query distance finder kernel")
-        return knl
-
-    @memoize_method
-    def get_center_is_closest_to_orig_panel_refiner(self, dimensions,
-                                                    coord_dtype, box_id_dtype,
-                                                    peer_list_idx_dtype,
-                                                    particle_id_dtype,
-                                                    max_levels):
-        return CENTER_IS_CLOSEST_TO_ORIG_PANEL_REFINER.generate(self.cl_context,
                 dimensions, coord_dtype, box_id_dtype, peer_list_idx_dtype,
                 max_levels,
                 extra_type_aliases=(("particle_id_t", particle_id_dtype),))
 
     @memoize_method
-    def get_center_is_far_from_nonneighbor_panel_refiner(self, dimensions,
-                                                         coord_dtype,
-                                                         box_id_dtype,
-                                                         peer_list_idx_dtype,
-                                                         particle_id_dtype,
-                                                         max_levels):
-        return CENTER_IS_FAR_FROM_NONNEIGHBOR_PANEL_REFINER.generate(self.cl_context,
+    def sufficient_source_quadrature_resolution_checker(
+            self, dimensions, coord_dtype, box_id_dtype, peer_list_idx_dtype,
+            particle_id_dtype, max_levels):
+        return SUFFICIENT_SOURCE_QUADRATURE_RESOLUTION_CHECKER.generate(
+                self.cl_context,
                 dimensions, coord_dtype, box_id_dtype, peer_list_idx_dtype,
                 max_levels,
                 extra_type_aliases=(("particle_id_t", particle_id_dtype),))
 
     @memoize_method
-    def get_2_to_1_panel_ratio_refiner(self):
-        knl = lp.make_kernel([
-            "{[panel]: 0<=panel<npanels}",
-            "{[ineighbor]: neighbor_start<=ineighbor<neighbor_stop}"
-            ],
-            """
-            for panel
-                <> neighbor_start = panel_adjacency_starts[panel]
-                <> neighbor_stop = panel_adjacency_starts[panel + 1]
-                for ineighbor
-                    <> neighbor = panel_adjacency_lists[ineighbor]
-
-                    <> oversize = (refine_flags_prev[panel] == 0
-                           and (
-                               (panel_sizes[panel] > 2 * panel_sizes[neighbor]) or
-                               (panel_sizes[panel] > panel_sizes[neighbor] and
-                                   refine_flags_prev[neighbor] == 1)))
-
-                    if oversize
-                        refine_flags[panel] = 1
-                        refine_flags_updated = 1 {id=write_refine_flags_updated}
-                    end
-                end
-            end
-            """, [
-            lp.GlobalArg("panel_adjacency_lists", shape=None),
-            "..."
-            ],
-            options="return_dict",
-            silenced_warnings="write_race(write_refine_flags_updated)",
-            name="refine_2_to_1_adj_panel_size_ratio")
-        knl = lp.split_iname(knl, "panel", 128, inner_tag="l.0", outer_tag="g.0")
-        return knl
-
-    @memoize_method
-    def get_helmholtz_k_to_panel_ratio_refiner(self):
+    def kernel_length_scale_to_panel_size_ratio_checker(self):
         knl = lp.make_kernel(
             "{[panel]: 0<=panel<npanels}",
             """
             for panel
-                <> oversize = panel_sizes[panel] * helmholtz_k > 5
+                <> oversize = panel_sizes[panel] > kernel_length_scale
                 if oversize
                     refine_flags[panel] = 1
                     refine_flags_updated = 1 {id=write_refine_flags_updated}
@@ -389,21 +239,49 @@ class QBXLayerPotentialSourceRefiner(DiscrPlotterMixin):
             """,
             options="return_dict",
             silenced_warnings="write_race(write_refine_flags_updated)",
-            name="refine_helmholtz_k_to_panel_size_ratio")
+            name="refine_kernel_length_scale_to_panel_size_ratio")
         knl = lp.split_iname(knl, "panel", 128, inner_tag="l.0", outer_tag="g.0")
         return knl
 
-    # }}}
+    @memoize_method
+    def tree_builder(self):
+        from boxtree.tree_build import TreeBuilder
+        return TreeBuilder(self.cl_context)
+
+    @memoize_method
+    def peer_list_finder(self):
+        from boxtree.area_query import PeerListFinder
+        return PeerListFinder(self.cl_context)
+
+    def get_wrangler(self, queue):
+        """
+        :arg queue:
+        """
+        return RefinerWrangler(self, queue)
+
+# }}}
+
+
+# {{{ wrangler
 
-    # {{{ refinement triggering
+class RefinerWrangler(object):
+
+    def __init__(self, code_container, queue):
+        self.code_container = code_container
+        self.queue = queue
+
+    # {{{ check subroutines for conditions 1-3
+
+    def check_expansion_disks_undisturbed_by_sources(self,
+            lpot_source, tree, peer_lists, refine_flags,
+            debug, wait_for=None):
 
-    def refinement_check_center_is_closest_to_orig_panel(self, queue, tree,
-            lpot_source, peer_lists, refine_flags, debug, wait_for=None):
         # Avoid generating too many kernels.
         from pytools import div_ceil
-        max_levels = 10 * div_ceil(tree.nlevels, 10)
+        max_levels = MAX_LEVELS_INCREMENT * div_ceil(
+                tree.nlevels, MAX_LEVELS_INCREMENT)
 
-        knl = self.get_center_is_closest_to_orig_panel_refiner(
+        knl = self.code_container.expansion_disk_undisturbed_by_sources_checker(
                 tree.dimensions,
                 tree.coord_dtype, tree.box_id_dtype,
                 peer_lists.peer_list_starts.dtype,
@@ -415,26 +293,32 @@ class QBXLayerPotentialSourceRefiner(DiscrPlotterMixin):
         if debug:
             npanels_to_refine_prev = cl.array.sum(refine_flags).get()
 
-        found_panel_to_refine = cl.array.zeros(queue, 1, np.int32)
+        found_panel_to_refine = cl.array.zeros(self.queue, 1, np.int32)
         found_panel_to_refine.finish()
+        unwrap_args = AreaQueryElementwiseTemplate.unwrap_args
+
+        # FIXME: This shouldn't be by panel
+        center_danger_zone_radii_by_panel = \
+                lpot_source._expansion_radii("npanels")
 
         evt = knl(
             *unwrap_args(
                 tree, peer_lists,
-                tree.box_to_qbx_panel_starts,
-                tree.box_to_qbx_panel_lists,
+                tree.box_to_qbx_source_starts,
+                tree.box_to_qbx_source_lists,
                 tree.qbx_panel_to_source_starts,
                 tree.qbx_panel_to_center_starts,
                 tree.qbx_user_source_slice.start,
                 tree.qbx_user_center_slice.start,
                 tree.sorted_target_ids,
-                lpot_source.panel_sizes("npanels"),
+                center_danger_zone_radii_by_panel,
                 tree.nqbxpanels,
                 refine_flags,
                 found_panel_to_refine,
                 *tree.sources),
             range=slice(tree.nqbxcenters),
-            queue=queue)
+            queue=self.queue,
+            wait_for=wait_for)
 
         cl.wait_for_events([evt])
 
@@ -448,51 +332,51 @@ class QBXLayerPotentialSourceRefiner(DiscrPlotterMixin):
 
         return found_panel_to_refine.get()[0] == 1
 
-    def refinement_check_center_is_far_from_nonneighbor_panels(self, queue,
-                tree, lpot_source, peer_lists, tq_dists, refine_flags, debug,
-                wait_for=None):
+    def check_sufficient_source_quadrature_resolution(
+            self, lpot_source, tree, peer_lists, refine_flags, debug,
+            wait_for=None):
+
         # Avoid generating too many kernels.
         from pytools import div_ceil
-        max_levels = 10 * div_ceil(tree.nlevels, 10)
+        max_levels = MAX_LEVELS_INCREMENT * div_ceil(
+                tree.nlevels, MAX_LEVELS_INCREMENT)
 
-        knl = self.get_center_is_far_from_nonneighbor_panel_refiner(
+        knl = self.code_container.sufficient_source_quadrature_resolution_checker(
                 tree.dimensions,
                 tree.coord_dtype, tree.box_id_dtype,
                 peer_lists.peer_list_starts.dtype,
                 tree.particle_id_dtype,
                 max_levels)
-
-        logger.info("refiner: checking center is far from nonneighbor panels")
-
         if debug:
             npanels_to_refine_prev = cl.array.sum(refine_flags).get()
 
-        found_panel_to_refine = cl.array.zeros(queue, 1, np.int32)
+        logger.info("refiner: checking sufficient quadrature resolution")
+
+        found_panel_to_refine = cl.array.zeros(self.queue, 1, np.int32)
         found_panel_to_refine.finish()
 
-        adjacency = self.get_adjacency_on_device(queue, lpot_source)
+        source_danger_zone_radii_by_panel = (
+                lpot_source._fine_panel_sizes("npanels")
+                .with_queue(self.queue) / 4)
+        unwrap_args = AreaQueryElementwiseTemplate.unwrap_args
 
         evt = knl(
             *unwrap_args(
                 tree, peer_lists,
-                tree.box_to_qbx_panel_starts,
-                tree.box_to_qbx_panel_lists,
+                tree.box_to_qbx_center_starts,
+                tree.box_to_qbx_center_lists,
                 tree.qbx_panel_to_source_starts,
-                tree.qbx_panel_to_center_starts,
                 tree.qbx_user_source_slice.start,
                 tree.qbx_user_center_slice.start,
-                tree.qbx_user_panel_slice.start,
                 tree.sorted_target_ids,
-                lpot_source.panel_sizes("npanels"),
-                adjacency.adjacency_starts,
-                adjacency.adjacency_lists,
+                source_danger_zone_radii_by_panel,
                 tree.nqbxpanels,
-                tq_dists,
                 refine_flags,
                 found_panel_to_refine,
                 *tree.sources),
-            range=slice(tree.nqbxcenters),
-            queue=queue)
+            range=slice(tree.nqbxsources),
+            queue=self.queue,
+            wait_for=wait_for)
 
         cl.wait_for_events([evt])
 
@@ -502,24 +386,24 @@ class QBXLayerPotentialSourceRefiner(DiscrPlotterMixin):
                 logger.debug("refiner: found {} panel(s) to refine".format(
                     npanels_to_refine - npanels_to_refine_prev))
 
-        logger.info("refiner: done checking center is far from nonneighbor panels")
+        logger.info("refiner: done checking sufficient quadrature resolution")
 
         return found_panel_to_refine.get()[0] == 1
 
-    def refinement_check_helmholtz_k_to_panel_size_ratio(self, queue, lpot_source,
-                helmholtz_k, refine_flags, debug, wait_for=None):
-        knl = self.get_helmholtz_k_to_panel_ratio_refiner()
+    def check_kernel_length_scale_to_panel_size_ratio(self, lpot_source,
+            kernel_length_scale, refine_flags, debug, wait_for=None):
+        knl = self.code_container.kernel_length_scale_to_panel_size_ratio_checker()
 
-        logger.info("refiner: checking helmholtz k to panel size ratio")
+        logger.info("refiner: checking kernel length scale to panel size ratio")
 
         if debug:
             npanels_to_refine_prev = cl.array.sum(refine_flags).get()
 
-        evt, out = knl(queue,
-                       panel_sizes=lpot_source.panel_sizes("npanels"),
+        evt, out = knl(self.queue,
+                       panel_sizes=lpot_source._panel_sizes("npanels"),
                        refine_flags=refine_flags,
                        refine_flags_updated=np.array(0),
-                       helmholtz_k=np.array(helmholtz_k),
+                       kernel_length_scale=np.array(kernel_length_scale),
                        wait_for=wait_for)
 
         cl.wait_for_events([evt])
@@ -530,246 +414,229 @@ class QBXLayerPotentialSourceRefiner(DiscrPlotterMixin):
                 logger.debug("refiner: found {} panel(s) to refine".format(
                     npanels_to_refine - npanels_to_refine_prev))
 
-        logger.info("refiner: done checking helmholtz k to panel size ratio")
+        logger.info("refiner: done checking kernel length scale to panel size ratio")
 
         return (out["refine_flags_updated"].get() == 1).all()
 
-    def refinement_check_2_to_1_panel_size_ratio(self, queue, lpot_source,
-                refine_flags, debug, wait_for=None):
-        knl = self.get_2_to_1_panel_ratio_refiner()
-        adjacency = self.get_adjacency_on_device(queue, lpot_source)
+    # }}}
 
-        refine_flags_updated = False
+    def build_tree(self, lpot_source, use_base_fine_discr=False):
+        tb = self.code_container.tree_builder()
+        from pytential.qbx.utils import build_tree_with_qbx_metadata
+        return build_tree_with_qbx_metadata(
+                self.queue, tb, lpot_source, use_base_fine_discr=use_base_fine_discr)
 
-        logger.info("refiner: checking 2-to-1 panel size ratio")
+    def find_peer_lists(self, tree):
+        plf = self.code_container.peer_list_finder()
+        peer_lists, evt = plf(self.queue, tree)
+        cl.wait_for_events([evt])
+        return peer_lists
 
-        if debug:
-            npanels_to_refine_prev = cl.array.sum(refine_flags).get()
+    def refine(self, density_discr, refiner, refine_flags, factory, debug):
+        """
+        Refine the underlying mesh and discretization.
+        """
+        if isinstance(refine_flags, cl.array.Array):
+            refine_flags = refine_flags.get(self.queue)
+        refine_flags = refine_flags.astype(np.bool)
 
-        # Iterative refinement until no more panels can be marked
-        while True:
-            evt, out = knl(queue,
-                           npanels=lpot_source.density_discr.mesh.nelements,
-                           panel_sizes=lpot_source.panel_sizes("npanels"),
-                           refine_flags=refine_flags,
-                           # It's safe to pass this here, as the resulting data
-                           # race won't affect the final result of the
-                           # computation.
-                           refine_flags_prev=refine_flags,
-                           refine_flags_updated=np.array(0),
-                           panel_adjacency_starts=adjacency.adjacency_starts,
-                           panel_adjacency_lists=adjacency.adjacency_lists,
-                           wait_for=wait_for)
-
-            cl.wait_for_events([evt])
-
-            if (out["refine_flags_updated"].get() == 1).all():
-                refine_flags_updated = True
-            else:
-                break
+        logger.info("refiner: calling meshmode")
 
-        if debug:
-            npanels_to_refine = cl.array.sum(refine_flags).get()
-            if npanels_to_refine > npanels_to_refine_prev:
-                logger.debug("refiner: found {} panel(s) to refine".format(
-                    npanels_to_refine - npanels_to_refine_prev))
+        refiner.refine(refine_flags)
+        from meshmode.discretization.connection import make_refinement_connection
+        conn = make_refinement_connection(refiner, density_discr, factory)
 
-        logger.info("refiner: done checking 2-to-1 panel size ratio")
+        logger.info("refiner: done calling meshmode")
 
-        return refine_flags_updated
+        return conn
 
-    # }}}
+# }}}
 
-    # {{{ other utilities
 
-    def get_tunnel_query_dists(self, queue, tree, lpot_source):
-        """
-        Compute radii for the tubular neighborhood around each panel center of mass.
-        """
-        nqbxpanels = lpot_source.density_discr.mesh.nelements
-        # atomic_max only works on float32
-        tq_dists = cl.array.zeros(queue, nqbxpanels, np.float32)
-        tq_dists.finish()
-
-        knl = self.get_tunnel_query_distance_finder(tree.dimensions,
-                tree.coord_dtype, tree.particle_id_dtype)
-
-        evt = knl(tree.qbx_user_source_slice.start,
-                  tree.qbx_user_panel_slice.start,
-                  nqbxpanels,
-                  tree.qbx_panel_to_source_starts,
-                  tree.sorted_target_ids,
-                  lpot_source.panel_sizes("npanels"),
-                  tq_dists,
-                  *tree.sources,
-                  queue=queue,
-                  range=slice(tree.nqbxsources))
+class RefinerNotConvergedWarning(UserWarning):
+    pass
 
-        cl.wait_for_events([evt])
 
-        if tree.coord_dtype != tq_dists.dtype:
-            tq_dists = tq_dists.astype(tree.coord_dtype)
+def make_empty_refine_flags(queue, lpot_source, use_base_fine_discr=False):
+    """Return an array on the device suitable for use as element refine flags.
 
-        return tq_dists, evt
+    :arg queue: An instance of :class:`pyopencl.CommandQueue`.
+    :arg lpot_source: An instance of :class:`QBXLayerPotentialSource`.
 
-    def get_adjacency_on_device(self, queue, lpot_source):
-        """
-        Take adjacency information from the mesh and place it onto the device.
-        """
-        from boxtree.tools import DeviceDataRecord
-        adjacency = lpot_source.density_discr.mesh.nodal_adjacency
-        adjacency_starts = cl.array.to_device(queue, adjacency.neighbors_starts)
-        adjacency_lists = cl.array.to_device(queue, adjacency.neighbors)
+    :returns: A :class:`pyopencl.array.Array` suitable for use as refine flags,
+        initialized to zero.
+    """
+    discr = (lpot_source.base_fine_density_discr
+            if use_base_fine_discr
+            else lpot_source.density_discr)
+    result = cl.array.zeros(queue, discr.mesh.nelements, np.int32)
+    result.finish()
+    return result
 
-        return DeviceDataRecord(
-            adjacency_starts=adjacency_starts,
-            adjacency_lists=adjacency_lists)
 
-    def get_refine_flags(self, queue, lpot_source):
-        """
-        Return an array on the device suitable for use as element refine flags.
+# {{{ main entry point
 
-        :arg queue: An instance of :class:`pyopencl.CommandQueue`.
-        :arg lpot_source: An instance of :class:`QBXLayerPotentialSource`.
+def refine_for_global_qbx(lpot_source, code_container,
+        group_factory, fine_group_factory, kernel_length_scale=None,
+        # FIXME: Set debug=False once everything works.
+        refine_flags=None, debug=True, maxiter=50):
+    """
+    Entry point for calling the refiner.
 
-        :returns: An instance of :class:`pyopencl.array.Array` suitable for
-            use as refine flags, initialized to zero.
-        """
-        result = cl.array.zeros(
-            queue, lpot_source.density_discr.mesh.nelements, np.int32)
-        return result, result.events[0]
+    :arg lpot_source: An instance of :class:`QBXLayerPotentialSource`.
 
-    # }}}
+    :arg code_container: An instance of :class:`RefinerCodeContainer`.
 
-    def refine(self, queue, lpot_source, refine_flags, refiner, factory, debug):
-        """
-        Refine the underlying mesh and discretization.
-        """
-        if isinstance(refine_flags, cl.array.Array):
-            refine_flags = refine_flags.get(queue)
-        refine_flags = refine_flags.astype(np.bool)
+    :arg group_factory: An instance of
+        :class:`meshmode.mesh.discretization.ElementGroupFactory`. Used for
+        discretizing the coarse refined mesh.
 
-        logger.info("refiner: calling meshmode")
+    :arg fine_group_factory: An instance of
+        :class:`meshmode.mesh.discretization.ElementGroupFactory`. Used for
+        oversample the refined mesh at the finest level.
 
-        refiner.refine(refine_flags)
-        from meshmode.discretization.connection import make_refinement_connection
+    :arg kernel_length_scale: The kernel length scale, or *None* if not
+        applicable. All panels are refined to below this size.
 
-        conn = make_refinement_connection(
-                refiner, lpot_source.density_discr,
-                factory)
+    :arg refine_flags: A :class:`pyopencl.array.Array` indicating which
+        panels should get refined initially, or `None` if no initial
+        refinement should be done. Should have size equal to the number of
+        panels. See also :func:`make_empty_refine_flags()`.
 
-        logger.info("refiner: done calling meshmode")
+    :arg maxiter: The maximum number of refiner iterations.
 
-        new_density_discr = conn.to_discr
+    :returns: A tuple ``(lpot_source, *conn*)`` where ``lpot_source`` is the
+        refined layer potential source, and ``conn`` is a
+        :class:`meshmode.discretization.connection.DiscretizationConnection`
+        going from the original mesh to the refined mesh.
+    """
 
-        new_lpot_source = lpot_source.copy(
-                density_discr=new_density_discr,
-                refined_for_global_qbx=True)
+    # TODO: Stop doing redundant checks by avoiding panels which no longer need
+    # refinement.
 
-        return new_lpot_source, conn
+    from meshmode.mesh.refinement import Refiner
+    from meshmode.discretization.connection import (
+            ChainedDiscretizationConnection, make_same_mesh_connection)
 
-    def __call__(self, lpot_source, discr_factory, helmholtz_k=None,
-                 # FIXME: Set debug=False once everything works.
-                 refine_flags=None, debug=True, maxiter=50):
-        """
-        Entry point for calling the refiner.
+    with cl.CommandQueue(lpot_source.cl_context) as queue:
+        wrangler = code_container.get_wrangler(queue)
 
-        :arg lpot_source: An instance of :class:`QBXLayerPotentialSource`.
+        refiner = Refiner(lpot_source.density_discr.mesh)
+        connections = []
 
-        :arg group_factory: An instance of
-            :class:`meshmode.mesh.discretization.ElementGroupFactory`. Used for
-            discretizing the refined mesh.
+        # Do initial refinement.
+        if refine_flags is not None:
+            conn = wrangler.refine(
+                    lpot_source.density_discr, refiner, refine_flags, group_factory,
+                    debug)
+            connections.append(conn)
+            lpot_source = lpot_source.copy(density_discr=conn.to_discr)
 
-        :arg helmholtz_k: The Helmholtz parameter, or `None` if not applicable.
+        # {{{ first stage refinement
 
-        :arg refine_flags: A :class:`pyopencl.array.Array` indicating which
-            panels should get refined initially, or `None` if no initial
-            refinement should be done. Should have size equal to the number of
-            panels. See also :meth:`get_refine_flags()`.
+        must_refine = True
+        niter = 0
 
-        :returns: A tuple ``(lpot_source, conns)`` where ``lpot_source`` is the
-            refined layer potential source, and ``conns`` is a list of
-            :class:`meshmode.discretization.connection.DiscretizationConnection`
-            objects going from the original mesh to the refined mesh.
-        """
-        from meshmode.mesh.refinement import Refiner
-        refiner = Refiner(lpot_source.density_discr.mesh)
-        connections = []
+        while must_refine:
+            must_refine = False
+            niter += 1
 
-        lpot_source = lpot_source.copy(
-            debug=debug,
-            refined_for_global_qbx=True)
+            if niter > maxiter:
+                from warnings import warn
+                warn(
+                        "Max iteration count reached in QBX layer potential source"
+                        " refiner.",
+                        RefinerNotConvergedWarning)
+                break
 
-        with cl.CommandQueue(self.cl_context) as queue:
-            if refine_flags:
-                lpot_source, conn = self.refine(
-                            queue, lpot_source, refine_flags, refiner, discr_factory,
-                            debug)
+            # Build tree and auxiliary data.
+            # FIXME: The tree should not have to be rebuilt at each iteration.
+            tree = wrangler.build_tree(lpot_source)
+            peer_lists = wrangler.find_peer_lists(tree)
+            refine_flags = make_empty_refine_flags(queue, lpot_source)
+
+            # Check condition 1.
+            must_refine |= wrangler.check_expansion_disks_undisturbed_by_sources(
+                    lpot_source, tree, peer_lists, refine_flags, debug)
+
+            # Check condition 3.
+            if kernel_length_scale is not None:
+                must_refine |= (
+                        wrangler.check_kernel_length_scale_to_panel_size_ratio(
+                            lpot_source, kernel_length_scale, refine_flags, debug))
+
+            if must_refine:
+                conn = wrangler.refine(
+                        lpot_source.density_discr, refiner, refine_flags,
+                        group_factory, debug)
                 connections.append(conn)
+                lpot_source = lpot_source.copy(density_discr=conn.to_discr)
 
-            done_refining = False
-            niter = 0
+            del tree
+            del refine_flags
+            del peer_lists
 
-            while not done_refining:
-                niter += 1
+        # }}}
 
-                if niter > maxiter:
-                    logger.warning(
-                        "Max iteration count reached in QBX layer potential source"
-                        " refiner.")
-                    break
+        # {{{ second stage refinement
 
-                # Build tree and auxiliary data.
-                # FIXME: The tree should not have to be rebuilt at each iteration.
-                tree = self.tree_builder(queue, lpot_source)
-                wait_for = []
+        must_refine = True
+        niter = 0
+        fine_connections = []
 
-                peer_lists, evt = self.peer_list_finder(queue, tree, wait_for)
-                wait_for = [evt]
+        base_fine_density_discr = lpot_source.density_discr
 
-                refine_flags, evt = self.get_refine_flags(queue, lpot_source)
-                wait_for.append(evt)
+        while must_refine:
+            must_refine = False
+            niter += 1
 
-                tq_dists, evt = self.get_tunnel_query_dists(queue, tree, lpot_source)
-                wait_for.append(evt)
+            if niter > maxiter:
+                from warnings import warn
+                warn(
+                        "Max iteration count reached in QBX layer potential source"
+                        " refiner.",
+                        RefinerNotConvergedWarning)
+                break
 
-                # Run refinement checkers.
-                must_refine = False
+            # Build tree and auxiliary data.
+            # FIXME: The tree should not have to be rebuilt at each iteration.
+            tree = wrangler.build_tree(lpot_source, use_base_fine_discr=True)
+            peer_lists = wrangler.find_peer_lists(tree)
+            refine_flags = make_empty_refine_flags(
+                    queue, lpot_source, use_base_fine_discr=True)
 
-                must_refine |= \
-                        self.refinement_check_center_is_closest_to_orig_panel(
-                            queue, tree, lpot_source, peer_lists, refine_flags,
-                            debug, wait_for)
+            must_refine |= wrangler.check_sufficient_source_quadrature_resolution(
+                    lpot_source, tree, peer_lists, refine_flags, debug)
 
-                must_refine |= \
-                        self.refinement_check_center_is_far_from_nonneighbor_panels(
-                            queue, tree, lpot_source, peer_lists, tq_dists,
-                            refine_flags, debug, wait_for)
+            if must_refine:
+                conn = wrangler.refine(
+                        base_fine_density_discr,
+                        refiner, refine_flags, group_factory, debug)
+                base_fine_density_discr = conn.to_discr
+                fine_connections.append(conn)
+                lpot_source = lpot_source.copy(
+                        base_resampler=ChainedDiscretizationConnection(
+                            fine_connections))
 
-                must_refine |= \
-                        self.refinement_check_2_to_1_panel_size_ratio(
-                            queue, lpot_source, refine_flags, debug, wait_for)
+            del tree
+            del refine_flags
+            del peer_lists
 
-                if helmholtz_k:
-                    must_refine |= \
-                            self.refinement_check_helmholtz_k_to_panel_size_ratio(
-                                queue, lpot_source, helmholtz_k, refine_flags, debug,
-                                wait_for)
+        # }}}
 
-                if must_refine:
-                    lpot_source, conn = self.refine(
-                            queue, lpot_source, refine_flags, refiner, discr_factory,
-                            debug)
-                    connections.append(conn)
+    lpot_source = lpot_source.copy(debug=debug, refined_for_global_qbx=True)
 
-                del tree
-                del peer_lists
-                del tq_dists
-                del refine_flags
-                done_refining = not must_refine
+    if len(connections) == 0:
+        # FIXME: This is inefficient
+        connection = make_same_mesh_connection(
+                lpot_source.density_discr,
+                lpot_source.density_discr)
+    else:
+        connection = ChainedDiscretizationConnection(connections)
 
-        return lpot_source, connections
+    return lpot_source, connection
 
 # }}}
 
+
 # vim: foldmethod=marker:filetype=pyopencl
diff --git a/pytential/qbx/target_assoc.py b/pytential/qbx/target_assoc.py
index 8890d992106e09509ceee424d73f8fa13ec9fa4f..4255d865f753da087e1cca1e7942248d14b684b0 100644
--- a/pytential/qbx/target_assoc.py
+++ b/pytential/qbx/target_assoc.py
@@ -36,7 +36,7 @@ from boxtree.tools import DeviceDataRecord
 from boxtree.area_query import AreaQueryElementwiseTemplate
 from boxtree.tools import InlineBinarySearch
 from pytential.qbx.utils import (
-    QBX_TREE_C_PREAMBLE, QBX_TREE_MAKO_DEFS, DiscrPlotterMixin)
+    QBX_TREE_C_PREAMBLE, QBX_TREE_MAKO_DEFS)
 
 
 unwrap_args = AreaQueryElementwiseTemplate.unwrap_args
@@ -128,7 +128,7 @@ QBX_TARGET_MARKER = AreaQueryElementwiseTemplate(
         particle_id_t source_offset,
         particle_id_t target_offset,
         particle_id_t *sorted_target_ids,
-        coord_t *panel_sizes,
+        coord_t *tunnel_radius_by_source,
         coord_t *box_to_search_dist,
 
         /* output */
@@ -140,7 +140,7 @@ QBX_TARGET_MARKER = AreaQueryElementwiseTemplate(
             coord_t *particles_${ax},
         %endfor
     """,
-    ball_center_and_radius_expr=QBX_TREE_C_PREAMBLE + QBX_TREE_MAKO_DEFS + r"""
+    ball_center_and_radius_expr=QBX_TREE_C_PREAMBLE + QBX_TREE_MAKO_DEFS + r"""//CL//
         coord_vec_t tgt_coords;
         ${load_particle("INDEX_FOR_TARGET_PARTICLE(i)", "tgt_coords")}
         {
@@ -149,7 +149,7 @@ QBX_TARGET_MARKER = AreaQueryElementwiseTemplate(
             ${ball_radius} = box_to_search_dist[my_box];
         }
     """,
-    leaf_found_op=QBX_TREE_MAKO_DEFS + r"""
+    leaf_found_op=QBX_TREE_MAKO_DEFS + r"""//CL//
         for (particle_id_t source_idx = box_to_source_starts[${leaf_box_id}];
              source_idx < box_to_source_starts[${leaf_box_id} + 1];
              ++source_idx)
@@ -158,7 +158,8 @@ QBX_TARGET_MARKER = AreaQueryElementwiseTemplate(
             coord_vec_t source_coords;
             ${load_particle("INDEX_FOR_SOURCE_PARTICLE(source)", "source_coords")}
 
-            if (distance(source_coords, tgt_coords) <= panel_sizes[source] / 2)
+            if (distance(source_coords, tgt_coords)
+                    <= tunnel_radius_by_source[source])
             {
                 target_status[i] = MARKED_QBX_CENTER_PENDING;
                 *found_target_close_to_panel = 1;
@@ -177,9 +178,8 @@ QBX_CENTER_FINDER = AreaQueryElementwiseTemplate(
         particle_id_t center_offset,
         particle_id_t target_offset,
         particle_id_t *sorted_target_ids,
-        coord_t *panel_sizes,
+        coord_t *expansion_radii_by_center_with_stick_out,
         coord_t *box_to_search_dist,
-        coord_t stick_out_factor,
         int *target_flags,
 
         /* input/output */
@@ -222,7 +222,7 @@ QBX_CENTER_FINDER = AreaQueryElementwiseTemplate(
             coord_t my_dist_to_center = distance(tgt_coords, center_coords);
 
             if (my_dist_to_center
-                    <= (panel_sizes[center] / 2) * (1 + stick_out_factor)
+                    <= expansion_radii_by_center_with_stick_out[center]
                 && my_dist_to_center < min_dist_to_center[i])
             {
                 target_status[i] = MARKED_QBX_CENTER_FOUND;
@@ -245,7 +245,7 @@ QBX_FAILED_TARGET_ASSOCIATION_REFINER = AreaQueryElementwiseTemplate(
         particle_id_t target_offset,
         int npanels,
         particle_id_t *sorted_target_ids,
-        coord_t *panel_sizes,
+        coord_t *tunnel_radius_by_source,
         int *target_status,
         coord_t *box_to_search_dist,
 
@@ -258,7 +258,7 @@ QBX_FAILED_TARGET_ASSOCIATION_REFINER = AreaQueryElementwiseTemplate(
             coord_t *particles_${ax},
         %endfor
     """,
-    ball_center_and_radius_expr=QBX_TREE_C_PREAMBLE + QBX_TREE_MAKO_DEFS + r"""
+    ball_center_and_radius_expr=QBX_TREE_C_PREAMBLE + QBX_TREE_MAKO_DEFS + r"""//CL//
         coord_vec_t target_coords;
         ${load_particle("INDEX_FOR_TARGET_PARTICLE(i)", "target_coords")}
         {
@@ -267,7 +267,7 @@ QBX_FAILED_TARGET_ASSOCIATION_REFINER = AreaQueryElementwiseTemplate(
             ${ball_radius} = box_to_search_dist[my_box];
         }
     """,
-    leaf_found_op=QBX_TREE_MAKO_DEFS + r"""
+    leaf_found_op=QBX_TREE_MAKO_DEFS + r"""//CL//
         for (particle_id_t source_idx = box_to_source_starts[${leaf_box_id}];
              source_idx < box_to_source_starts[${leaf_box_id} + 1];
              ++source_idx)
@@ -279,7 +279,7 @@ QBX_FAILED_TARGET_ASSOCIATION_REFINER = AreaQueryElementwiseTemplate(
 
             bool is_close =
                 distance(target_coords, source_coords)
-                <= panel_sizes[source] / 2;
+                <= tunnel_radius_by_source[source];
 
             if (is_close && target_status[i] == MARKED_QBX_CENTER_PENDING)
             {
@@ -318,11 +318,11 @@ class QBXTargetAssociation(DeviceDataRecord):
     pass
 
 
-class QBXTargetAssociator(DiscrPlotterMixin):
+class QBXTargetAssociator(object):
 
     def __init__(self, cl_context):
-        from pytential.qbx.utils import TreeWithQBXMetadataBuilder
-        self.tree_builder = TreeWithQBXMetadataBuilder(cl_context)
+        from boxtree.tree_build import TreeBuilder
+        self.tree_builder = TreeBuilder(cl_context)
         self.cl_context = cl_context
         from boxtree.area_query import PeerListFinder, SpaceInvaderQueryBuilder
         self.peer_list_finder = PeerListFinder(cl_context)
@@ -398,19 +398,37 @@ class QBXTargetAssociator(DiscrPlotterMixin):
         # Perform a space invader query over the sources.
         source_slice = tree.user_source_ids[tree.qbx_user_source_slice]
         sources = [axis.with_queue(queue)[source_slice] for axis in tree.sources]
-        panel_sizes = lpot_source.panel_sizes("nsources").with_queue(queue)
+        tunnel_radius_by_source = \
+                lpot_source._close_target_tunnel_radius("nsources").with_queue(queue)
+
+        # Target-marking algorithm (TGTMARK):
+        #
+        # (1) Use a space invader query to tag each leaf box that intersects with the
+        # "near-source-detection tunnel" with the distance to the closest source.
+        #
+        # (2) Do an area query around all targets with the radius resulting
+        # from the space invader query, enumerate sources in that vicinity.
+        # If a source is found whose distance to the target is less than the
+        # source's tunnel radius, mark that target as pending.
+        # (or below: mark the source for refinement)
+
+        # Note that this comment is referred to below by "TGTMARK". If you
+        # remove this comment or change the algorithm here, make sure that
+        # the reference below is still accurate.
 
         box_to_search_dist, evt = self.space_invader_query(
                 queue,
                 tree,
                 sources,
-                panel_sizes / 2,
+                tunnel_radius_by_source,
                 peer_lists,
                 wait_for=wait_for)
         wait_for = [evt]
 
         logger.info("target association: marking targets close to panels")
 
+        tunnel_radius_by_source = lpot_source._close_target_tunnel_radius("nsources")
+
         evt = knl(
             *unwrap_args(
                 tree, peer_lists,
@@ -419,7 +437,7 @@ class QBXTargetAssociator(DiscrPlotterMixin):
                 tree.qbx_user_source_slice.start,
                 tree.qbx_user_target_slice.start,
                 tree.sorted_target_ids,
-                panel_sizes,
+                tunnel_radius_by_source,
                 box_to_search_dist,
                 target_status,
                 found_target_close_to_panel,
@@ -463,13 +481,22 @@ class QBXTargetAssociator(DiscrPlotterMixin):
         center_slice = \
                 tree.sorted_target_ids[tree.qbx_user_center_slice].with_queue(queue)
         centers = [axis.with_queue(queue)[center_slice] for axis in tree.sources]
-        panel_sizes = lpot_source.panel_sizes("ncenters").with_queue(queue)
+        expansion_radii_by_center = \
+                lpot_source._expansion_radii("ncenters").with_queue(queue)
+        expansion_radii_by_center_with_stick_out = \
+                expansion_radii_by_center * (1 + stick_out_factor)
+
+        # Idea:
+        #
+        # (1) Tag leaf boxes around centers with max distance to usable center.
+        # (2) Area query from targets with those radii to find closest eligible
+        # center.
 
         box_to_search_dist, evt = self.space_invader_query(
                 queue,
                 tree,
                 centers,
-                panel_sizes * ((1 + stick_out_factor) / 2),
+                expansion_radii_by_center_with_stick_out,
                 peer_lists,
                 wait_for=wait_for)
         wait_for = [evt]
@@ -490,9 +517,8 @@ class QBXTargetAssociator(DiscrPlotterMixin):
                 tree.qbx_user_center_slice.start,
                 tree.qbx_user_target_slice.start,
                 tree.sorted_target_ids,
-                panel_sizes,
+                expansion_radii_by_center_with_stick_out,
                 box_to_search_dist,
-                stick_out_factor,
                 target_flags,
                 target_status,
                 target_assoc.target_to_center,
@@ -535,13 +561,16 @@ class QBXTargetAssociator(DiscrPlotterMixin):
         # Perform a space invader query over the sources.
         source_slice = tree.user_source_ids[tree.qbx_user_source_slice]
         sources = [axis.with_queue(queue)[source_slice] for axis in tree.sources]
-        panel_sizes = lpot_source.panel_sizes("nsources").with_queue(queue)
+        tunnel_radius_by_source = \
+                lpot_source._close_target_tunnel_radius("nsources").with_queue(queue)
+
+        # See (TGTMARK) above for algorithm.
 
         box_to_search_dist, evt = self.space_invader_query(
                 queue,
                 tree,
                 sources,
-                panel_sizes / 2,
+                tunnel_radius_by_source,
                 peer_lists,
                 wait_for=wait_for)
         wait_for = [evt]
@@ -558,7 +587,7 @@ class QBXTargetAssociator(DiscrPlotterMixin):
                 tree.qbx_user_target_slice.start,
                 tree.nqbxpanels,
                 tree.sorted_target_ids,
-                lpot_source.panel_sizes("nsources"),
+                lpot_source._close_target_tunnel_radius("nsources"),
                 target_status,
                 box_to_search_dist,
                 refine_flags,
@@ -654,8 +683,14 @@ class QBXTargetAssociator(DiscrPlotterMixin):
         """
 
         with cl.CommandQueue(self.cl_context) as queue:
-            tree = self.tree_builder(queue, lpot_source,
+            from pytential.qbx.utils import build_tree_with_qbx_metadata
+
+            tree = build_tree_with_qbx_metadata(
+                    queue,
+                    self.tree_builder,
+                    lpot_source,
                     [discr for discr, _ in target_discrs_and_qbx_sides])
+
             peer_lists, evt = self.peer_list_finder(queue, tree, wait_for)
             wait_for = [evt]
 
diff --git a/pytential/qbx/utils.py b/pytential/qbx/utils.py
index 60da4b5b2a4e914696ba0ea1083c0cc1a6f825d0..02a19e007eae86e90066224f97b0b261a02a7e6a 100644
--- a/pytential/qbx/utils.py
+++ b/pytential/qbx/utils.py
@@ -99,8 +99,8 @@ def get_interleaved_centers(queue, lpot_source):
     next to corresponding exterior centers.
     """
     knl = get_interleaver_kernel(lpot_source.density_discr.real_dtype)
-    int_centers = lpot_source.centers(-1)
-    ext_centers = lpot_source.centers(+1)
+    int_centers = get_centers_on_side(lpot_source, -1)
+    ext_centers = get_centers_on_side(lpot_source, +1)
 
     result = []
     wait_for = []
@@ -118,203 +118,433 @@ def get_interleaved_centers(queue, lpot_source):
 # }}}
 
 
-# {{{ discr plotter mixin
-
-class DiscrPlotterMixin(object):
-
-    def plot_discr(self, lpot_source, outfilename="discr.pdf"):
-        with cl.CommandQueue(self.cl_context) as queue:
-            tree = self.tree_builder(queue, lpot_source).get(queue=queue)
-            from boxtree.visualization import TreePlotter
-
-            import matplotlib
-            matplotlib.use('Agg')
-            import matplotlib.pyplot as plt
-            tp = TreePlotter(tree)
-            tp.draw_tree()
-            sources = (tree.sources[0], tree.sources[1])
-            sti = tree.sorted_target_ids
-            plt.plot(sources[0][sti[tree.qbx_user_source_slice]],
-                     sources[1][sti[tree.qbx_user_source_slice]],
-                     lw=0, marker=".", markersize=1, label="sources")
-            plt.plot(sources[0][sti[tree.qbx_user_center_slice]],
-                     sources[1][sti[tree.qbx_user_center_slice]],
-                     lw=0, marker=".", markersize=1, label="centers")
-            plt.plot(sources[0][sti[tree.qbx_user_target_slice]],
-                     sources[1][sti[tree.qbx_user_target_slice]],
-                     lw=0, marker=".", markersize=1, label="targets")
-            plt.axis("equal")
-            plt.legend()
-            plt.savefig(outfilename)
+# {{{ make interleaved radii
+
+def get_interleaved_radii(queue, lpot_source):
+    """
+    Return an array of shape (dim, ncenters) in which interior centers are placed
+    next to corresponding exterior centers.
+    """
+    knl = get_interleaver_kernel(lpot_source.density_discr.real_dtype)
+    radii = lpot_source._expansion_radii("nsources")
+
+    result = cl.array.empty(queue, len(radii) * 2, radii.dtype)
+    evt, _ = knl(queue, src1=radii, src2=radii, dst=result)
+    evt.wait()
+
+    return result
 
 # }}}
 
 
-# {{{ tree creation
+# {{{ panel sizes
+
+def panel_sizes(discr, last_dim_length):
+    if last_dim_length not in ("nsources", "ncenters", "npanels"):
+        raise ValueError(
+                "invalid value of last_dim_length: %s" % last_dim_length)
+
+    # To get the panel size this does the equivalent of (∫ 1 ds)**(1/dim).
+    # FIXME: Kernel optimizations
+
+    if last_dim_length == "nsources" or last_dim_length == "ncenters":
+        knl = lp.make_kernel(
+            "{[i,j,k]: 0<=i<nelements and 0<=j,k<nunit_nodes}",
+            "panel_sizes[i,j] = sum(k, ds[i,k])**(1/dim)",
+            name="compute_size")
+
+        def panel_size_view(discr, group_nr):
+            return discr.groups[group_nr].view
+
+    elif last_dim_length == "npanels":
+        knl = lp.make_kernel(
+            "{[i,j]: 0<=i<nelements and 0<=j<nunit_nodes}",
+            "panel_sizes[i] = sum(j, ds[i,j])**(1/dim)",
+            name="compute_size")
+        from functools import partial
+
+        def panel_size_view(discr, group_nr):
+            return partial(el_view, discr, group_nr)
+
+    else:
+        raise ValueError("unknown dim length specified")
+
+    knl = lp.fix_parameters(knl, dim=discr.dim)
+
+    with cl.CommandQueue(discr.cl_context) as queue:
+        from pytential import bind, sym
+        ds = bind(
+                discr,
+                sym.area_element(ambient_dim=discr.ambient_dim, dim=discr.dim)
+                * sym.QWeight()
+                )(queue)
+        panel_sizes = cl.array.empty(
+            queue, discr.nnodes
+            if last_dim_length in ("nsources", "ncenters")
+            else discr.mesh.nelements, discr.real_dtype)
+        for group_nr, group in enumerate(discr.groups):
+            _, (result,) = knl(queue,
+                nelements=group.nelements,
+                nunit_nodes=group.nunit_nodes,
+                ds=group.view(ds),
+                panel_sizes=panel_size_view(
+                    discr, group_nr)(panel_sizes))
+        panel_sizes.finish()
+        if last_dim_length == "ncenters":
+            from pytential.qbx.utils import get_interleaver_kernel
+            knl = get_interleaver_kernel(discr.real_dtype)
+            _, (panel_sizes,) = knl(queue, dstlen=2*discr.nnodes,
+                                    src1=panel_sizes, src2=panel_sizes)
+        return panel_sizes.with_queue(None)
+
+# }}}
 
-class TreeWithQBXMetadataBuilder(object):
 
-    MAX_REFINE_WEIGHT = 64
+# {{{ element centers of mass
 
-    class TreeWithQBXMetadata(Tree):
+def element_centers_of_mass(discr):
+    knl = lp.make_kernel(
+        """{[dim,k,i]:
+            0<=dim<ndims and
+            0<=k<nelements and
+            0<=i<nunit_nodes}""",
         """
-        .. attribute:: nqbxpanels
-        .. attribuet:: nqbxsources
-        .. attribute:: nqbxcenters
-        .. attribute:: nqbxtargets
+            panels[dim, k] = sum(i, nodes[dim, k, i])/nunit_nodes
+            """,
+        default_offset=lp.auto, name="find_panel_centers_of_mass")
+
+    knl = lp.fix_parameters(knl, ndims=discr.ambient_dim)
+
+    knl = lp.split_iname(knl, "k", 128, inner_tag="l.0", outer_tag="g.0")
+    knl = lp.tag_inames(knl, dict(dim="ilp"))
+
+    with cl.CommandQueue(discr.cl_context) as queue:
+        mesh = discr.mesh
+        panels = cl.array.empty(queue, (mesh.ambient_dim, mesh.nelements),
+                                dtype=discr.real_dtype)
+        for group_nr, group in enumerate(discr.groups):
+            _, (result,) = knl(queue,
+                nelements=group.nelements,
+                nunit_nodes=group.nunit_nodes,
+                nodes=group.view(discr.nodes()),
+                panels=el_view(discr, group_nr, panels))
+        panels.finish()
+        panels = panels.with_queue(None)
+        return tuple(panels[d, :] for d in range(mesh.ambient_dim))
 
-        .. attribute:: box_to_qbx_panel_starts
-        .. attribute:: box_to_qbx_panel_lists
+# }}}
 
-        .. attribute:: box_to_qbx_source_starts
-        .. attribute:: box_to_qbx_source_lists
 
-        .. attribute:: box_to_qbx_center_starts
-        .. attribute:: box_to_qbx_center_lists
+# {{{ compute center array
 
-        .. attribute:: box_to_qbx_target_starts
-        .. attribute:: box_to_qbx_target_lists
+def get_centers_on_side(lpot_src, sign):
+    adim = lpot_src.density_discr.ambient_dim
+    dim = lpot_src.density_discr.dim
 
-        .. attribute:: qbx_panel_to_source_starts
-        .. attribute:: qbx_panel_to_center_starts
+    from pytential import sym, bind
+    with cl.CommandQueue(lpot_src.cl_context) as queue:
+        nodes = bind(lpot_src.density_discr, sym.nodes(adim))(queue)
+        normals = bind(lpot_src.density_discr, sym.normal(adim, dim=dim))(queue)
+        expansion_radii = lpot_src._expansion_radii("nsources").with_queue(queue)
+        return (nodes + normals * sign * expansion_radii).as_vector(np.object)
 
-        .. attribute:: qbx_user_source_slice
-        .. attribute:: qbx_user_center_slice
-        .. attribute:: qbx_user_panel_slice
-        .. attribute:: qbx_user_target_slice
-        """
-        pass
+# }}}
 
-    def __init__(self, context):
-        self.context = context
-        from boxtree.tree_build import TreeBuilder
-        self.tree_builder = TreeBuilder(self.context)
 
-    def __call__(self, queue, lpot_source, targets_list=()):
-        """
-        Return a :class:`TreeWithQBXMetadata` built from the given layer
-        potential source.
+# {{{ el_view
 
-        :arg queue: An instance of :class:`pyopencl.CommandQueue`
+def el_view(discr, group_nr, global_array):
+    """Return a view of *global_array* of shape
+    ``(..., discr.groups[group_nr].nelements)``
+    where *global_array* is of shape ``(..., nelements)``,
+    where *nelements* is the global (per-discretization) node count.
+    """
 
-        :arg lpot_source: An instance of
-            :class:`pytential.qbx.NewQBXLayerPotentialSource`.
+    group = discr.groups[group_nr]
+    el_nr_base = sum(group.nelements for group in discr.groups[:group_nr])
 
-        :arg targets_list: A list of :class:`pytential.target.TargetBase`
-        """
-        # The ordering of particles is as follows:
-        # - sources go first
-        # - then centers
-        # - then panels (=centers of mass)
-        # - then targets
-
-        logger.info("start building tree with qbx metadata")
-
-        sources = lpot_source.density_discr.nodes()
-        centers = get_interleaved_centers(queue, lpot_source)
-        centers_of_mass = lpot_source.panel_centers_of_mass()
-        targets = (tgt.nodes() for tgt in targets_list)
-
-        particles = tuple(
-                cl.array.concatenate(dim_coords, queue=queue)
-                for dim_coords in zip(sources, centers, centers_of_mass, *targets))
-
-        # Counts
-        nparticles = len(particles[0])
-        npanels = len(centers_of_mass[0])
-        nsources = len(sources[0])
-        ncenters = len(centers[0])
-        # Each source gets an interior / exterior center.
-        assert 2 * nsources == ncenters
-        ntargets = sum(tgt.nnodes for tgt in targets_list)
-
-        # Slices
-        qbx_user_source_slice = slice(0, nsources)
-        panel_slice_start = 3 * nsources
-        qbx_user_center_slice = slice(nsources, panel_slice_start)
-        target_slice_start = panel_slice_start + npanels
-        qbx_user_panel_slice = slice(panel_slice_start, panel_slice_start + npanels)
-        qbx_user_target_slice = slice(target_slice_start,
-                                      target_slice_start + ntargets)
-
-        # Build tree with sources, centers, and centers of mass. Split boxes
-        # only because of sources.
-        refine_weights = cl.array.zeros(queue, nparticles, np.int32)
-        refine_weights[:nsources].fill(1)
-
-        refine_weights.finish()
-
-        tree, evt = self.tree_builder(queue, particles,
-                                      max_leaf_refine_weight=self.MAX_REFINE_WEIGHT,
-                                      refine_weights=refine_weights)
-
-        # Compute box => particle class relations
-        flags = refine_weights
-        del refine_weights
-        particle_classes = {}
-
-        for class_name, particle_slice, fixup in (
-                ("box_to_qbx_source", qbx_user_source_slice, 0),
-                ("box_to_qbx_target", qbx_user_target_slice, -target_slice_start),
-                ("box_to_qbx_center", qbx_user_center_slice, -nsources),
-                ("box_to_qbx_panel", qbx_user_panel_slice, -panel_slice_start)):
-            flags.fill(0)
-            flags[particle_slice].fill(1)
-            flags.finish()
-
-            from boxtree.tree import filter_target_lists_in_user_order
-            box_to_class = (
-                filter_target_lists_in_user_order(queue, tree, flags)
-                .with_queue(queue))
-
-            if fixup:
-                box_to_class.target_lists += fixup
-            particle_classes[class_name + "_starts"] = box_to_class.target_starts
-            particle_classes[class_name + "_lists"] = box_to_class.target_lists
-
-        del flags
-        del box_to_class
-
-        # Compute panel => source relation
-        qbx_panel_to_source_starts = cl.array.empty(
+    return global_array[
+        ..., el_nr_base:el_nr_base + group.nelements] \
+        .reshape(
+            global_array.shape[:-1]
+            + (group.nelements,))
+
+# }}}
+
+
+# {{{ discr plotter
+
+def plot_discr(lpot_source, outfilename="discr.pdf"):
+    with cl.CommandQueue(lpot_source.cl_context) as queue:
+        from boxtree.tree_builder import TreeBuilder
+        tree_builder = TreeBuilder(lpot_source.cl_context)
+        tree = tree_builder(queue, lpot_source).get(queue=queue)
+        from boxtree.visualization import TreePlotter
+
+        import matplotlib
+        matplotlib.use('Agg')
+        import matplotlib.pyplot as plt
+        tp = TreePlotter(tree)
+        tp.draw_tree()
+        sources = (tree.sources[0], tree.sources[1])
+        sti = tree.sorted_target_ids
+        plt.plot(sources[0][sti[tree.qbx_user_source_slice]],
+                 sources[1][sti[tree.qbx_user_source_slice]],
+                 lw=0, marker=".", markersize=1, label="sources")
+        plt.plot(sources[0][sti[tree.qbx_user_center_slice]],
+                 sources[1][sti[tree.qbx_user_center_slice]],
+                 lw=0, marker=".", markersize=1, label="centers")
+        plt.plot(sources[0][sti[tree.qbx_user_target_slice]],
+                 sources[1][sti[tree.qbx_user_target_slice]],
+                 lw=0, marker=".", markersize=1, label="targets")
+        plt.axis("equal")
+        plt.legend()
+        plt.savefig(outfilename)
+
+# }}}
+
+
+# {{{ tree-with-metadata: data structure
+
+class TreeWithQBXMetadata(Tree):
+    """A subclass of :class:`boxtree.tree.Tree`. Has all of that class's
+    attributes, along with the following:
+
+    .. attribute:: nqbxpanels
+    .. attribuet:: nqbxsources
+    .. attribute:: nqbxcenters
+    .. attribute:: nqbxtargets
+
+    .. ------------------------------------------------------------------------
+    .. rubric:: Box properties
+    .. ------------------------------------------------------------------------
+
+    .. rubric:: Box to QBX panels
+
+    .. attribute:: box_to_qbx_panel_starts
+
+        ``box_id_t [nboxes + 1]``
+
+    .. attribute:: box_to_qbx_panel_lists
+
+        ``particle_id_t [*]``
+
+    .. rubric:: Box to QBX sources
+
+    .. attribute:: box_to_qbx_source_starts
+
+        ``box_id_t [nboxes + 1]``
+
+    .. attribute:: box_to_qbx_source_lists
+
+        ``particle_id_t [*]``
+
+    .. rubric:: Box to QBX centers
+
+    .. attribute:: box_to_qbx_center_starts
+
+        ``box_id_t [nboxes + 1]``
+
+    .. attribute:: box_to_qbx_center_lists
+
+        ``particle_id_t [*]``
+
+    .. rubric:: Box to QBX targets
+
+    .. attribute:: box_to_qbx_target_starts
+
+        ``box_id_t [nboxes + 1]``
+
+    .. attribute:: box_to_qbx_target_lists
+
+        ``particle_id_t [*]``
+
+    .. ------------------------------------------------------------------------
+    .. rubric:: Panel properties
+    .. ------------------------------------------------------------------------
+
+    .. attribute:: qbx_panel_to_source_starts
+
+        ``particle_id_t [nqbxpanels + 1]``
+
+    .. attribute:: qbx_panel_to_center_starts
+
+        ``particle_id_t [nqbxpanels + 1]``
+
+    .. ------------------------------------------------------------------------
+    .. rubric:: Particle order indices
+    .. ------------------------------------------------------------------------
+
+    .. attribute:: qbx_user_source_slice
+    .. attribute:: qbx_user_center_slice
+    .. attribute:: qbx_user_panel_slice
+    .. attribute:: qbx_user_target_slice
+    """
+    pass
+
+# }}}
+
+
+# {{{ tree-with-metadata: creation
+
+MAX_REFINE_WEIGHT = 64
+
+
+def build_tree_with_qbx_metadata(
+        queue, tree_builder, lpot_source, targets_list=(),
+        use_base_fine_discr=False):
+    """Return a :class:`TreeWithQBXMetadata` built from the given layer
+    potential source. This contains particles of four different types:
+
+       * source particles and panel centers of mass either from
+         ``lpot_source.density_discr`` or ``lpot_source.base_fine_density_discr``
+       * centers from ``lpot_source.centers()``
+       * targets from ``targets_list``.
+
+    :arg queue: An instance of :class:`pyopencl.CommandQueue`
+
+    :arg lpot_source: An instance of
+        :class:`pytential.qbx.NewQBXLayerPotentialSource`.
+
+    :arg targets_list: A list of :class:`pytential.target.TargetBase`
+
+    :arg use_base_fine_discr: If *True*, builds a tree with sources/centers of
+        mass from ``lpot_source.base_fine_density_discr``. If *False* (default),
+        they are from ``lpot_source.density_discr``.
+    """
+    # The ordering of particles is as follows:
+    # - sources go first
+    # - then centers
+    # - then panels (=centers of mass)
+    # - then targets
+
+    logger.info("start building tree with qbx metadata")
+
+    sources = (
+            lpot_source.density_discr.nodes()
+            if not use_base_fine_discr
+            else lpot_source.fine_density_discr.nodes())
+
+    centers = get_interleaved_centers(queue, lpot_source)
+
+    centers_of_mass = (
+            lpot_source._panel_centers_of_mass()
+            if not use_base_fine_discr
+            else lpot_source._fine_panel_centers_of_mass())
+
+    targets = (tgt.nodes() for tgt in targets_list)
+
+    particles = tuple(
+            cl.array.concatenate(dim_coords, queue=queue)
+            for dim_coords in zip(sources, centers, centers_of_mass, *targets))
+
+    # Counts
+    nparticles = len(particles[0])
+    npanels = len(centers_of_mass[0])
+    nsources = len(sources[0])
+    ncenters = len(centers[0])
+    # Each source gets an interior / exterior center.
+    assert 2 * nsources == ncenters or use_base_fine_discr
+    ntargets = sum(tgt.nnodes for tgt in targets_list)
+
+    # Slices
+    qbx_user_source_slice = slice(0, nsources)
+
+    center_slice_start = nsources
+    qbx_user_center_slice = slice(center_slice_start, center_slice_start + ncenters)
+
+    panel_slice_start = center_slice_start + ncenters
+    qbx_user_panel_slice = slice(panel_slice_start, panel_slice_start + npanels)
+
+    target_slice_start = panel_slice_start + npanels
+    qbx_user_target_slice = slice(target_slice_start, target_slice_start + ntargets)
+
+    # Build tree with sources, centers, and centers of mass. Split boxes
+    # only because of sources.
+    refine_weights = cl.array.zeros(queue, nparticles, np.int32)
+    refine_weights[:nsources].fill(1)
+
+    refine_weights.finish()
+
+    tree, evt = tree_builder(queue, particles,
+            max_leaf_refine_weight=MAX_REFINE_WEIGHT,
+            refine_weights=refine_weights)
+
+    # Compute box => particle class relations
+    flags = refine_weights
+    del refine_weights
+    particle_classes = {}
+
+    for class_name, particle_slice, fixup in (
+            ("box_to_qbx_source", qbx_user_source_slice, 0),
+            ("box_to_qbx_target", qbx_user_target_slice, -target_slice_start),
+            ("box_to_qbx_center", qbx_user_center_slice, -center_slice_start),
+            ("box_to_qbx_panel", qbx_user_panel_slice, -panel_slice_start)):
+        flags.fill(0)
+        flags[particle_slice].fill(1)
+        flags.finish()
+
+        from boxtree.tree import filter_target_lists_in_user_order
+        box_to_class = (
+            filter_target_lists_in_user_order(queue, tree, flags)
+            .with_queue(queue))
+
+        if fixup:
+            box_to_class.target_lists += fixup
+        particle_classes[class_name + "_starts"] = box_to_class.target_starts
+        particle_classes[class_name + "_lists"] = box_to_class.target_lists
+
+    del flags
+    del box_to_class
+
+    # Compute panel => source relation
+    if use_base_fine_discr:
+        density_discr = lpot_source.fine_density_discr
+    else:
+        density_discr = lpot_source.density_discr
+
+    qbx_panel_to_source_starts = cl.array.empty(
             queue, npanels + 1, dtype=tree.particle_id_dtype)
-        el_offset = 0
-        for group in lpot_source.density_discr.groups:
-            qbx_panel_to_source_starts[el_offset:el_offset + group.nelements] = \
-                    cl.array.arange(queue, group.node_nr_base,
-                                    group.node_nr_base + group.nnodes,
-                                    group.nunit_nodes,
-                                    dtype=tree.particle_id_dtype)
-            el_offset += group.nelements
-        qbx_panel_to_source_starts[-1] = nsources
-
-        # Compute panel => center relation
-        qbx_panel_to_center_starts = 2 * qbx_panel_to_source_starts
-
-        # Transfer all tree attributes.
-        tree_attrs = {}
-        for attr_name in tree.__class__.fields:
-            try:
-                tree_attrs[attr_name] = getattr(tree, attr_name)
-            except AttributeError:
-                pass
-
-        tree_attrs.update(particle_classes)
-
-        logger.info("done building tree with qbx metadata")
-
-        return self.TreeWithQBXMetadata(
-            qbx_panel_to_source_starts=qbx_panel_to_source_starts,
-            qbx_panel_to_center_starts=qbx_panel_to_center_starts,
-            qbx_user_source_slice=qbx_user_source_slice,
-            qbx_user_panel_slice=qbx_user_panel_slice,
-            qbx_user_center_slice=qbx_user_center_slice,
-            qbx_user_target_slice=qbx_user_target_slice,
-            nqbxpanels=npanels,
-            nqbxsources=nsources,
-            nqbxcenters=ncenters,
-            nqbxtargets=ntargets,
-            **tree_attrs).with_queue(None)
+    el_offset = 0
+    for group in density_discr.groups:
+        qbx_panel_to_source_starts[el_offset:el_offset + group.nelements] = \
+                cl.array.arange(queue, group.node_nr_base,
+                                group.node_nr_base + group.nnodes,
+                                group.nunit_nodes,
+                                dtype=tree.particle_id_dtype)
+        el_offset += group.nelements
+    qbx_panel_to_source_starts[-1] = nsources
+
+    # Compute panel => center relation
+    qbx_panel_to_center_starts = (
+            2 * qbx_panel_to_source_starts
+            if not use_base_fine_discr
+            else None)
+
+    # Transfer all tree attributes.
+    tree_attrs = {}
+    for attr_name in tree.__class__.fields:
+        try:
+            tree_attrs[attr_name] = getattr(tree, attr_name)
+        except AttributeError:
+            pass
+
+    tree_attrs.update(particle_classes)
+
+    logger.info("done building tree with qbx metadata")
+
+    return TreeWithQBXMetadata(
+        qbx_panel_to_source_starts=qbx_panel_to_source_starts,
+        qbx_panel_to_center_starts=qbx_panel_to_center_starts,
+        qbx_user_source_slice=qbx_user_source_slice,
+        qbx_user_panel_slice=qbx_user_panel_slice,
+        qbx_user_center_slice=qbx_user_center_slice,
+        qbx_user_target_slice=qbx_user_target_slice,
+        nqbxpanels=npanels,
+        nqbxsources=nsources,
+        nqbxcenters=ncenters,
+        nqbxtargets=ntargets,
+        **tree_attrs).with_queue(None)
 
 # }}}
 
diff --git a/pytential/symbolic/mappers.py b/pytential/symbolic/mappers.py
index 0c30cb4bea611766c2ac50ec74ab180c080c3ce4..16679ac20f6cbfa4ca52469b3441a99e2ad49353 100644
--- a/pytential/symbolic/mappers.py
+++ b/pytential/symbolic/mappers.py
@@ -282,6 +282,30 @@ class DerivativeTaker(Mapper):
     def __init__(self, ambient_axis):
         self.ambient_axis = ambient_axis
 
+    def map_sum(self, expr):
+        from pymbolic.primitives import flattened_sum
+        return flattened_sum(tuple(self.rec(child) for child in expr.children))
+
+    def map_product(self, expr):
+        from pymbolic.primitives import is_constant
+        const = []
+        nonconst = []
+        for subexpr in expr.children:
+            if is_constant(subexpr):
+                const.append(subexpr)
+            else:
+                nonconst.append(subexpr)
+
+        if len(nonconst) > 1:
+            raise RuntimeError("DerivativeTaker doesn't support products with "
+                    "more than one non-constant")
+
+        if not nonconst:
+            nonconst = [1]
+
+        from pytools import product
+        return product(const) * self.rec(nonconst[0])
+
     def map_int_g(self, expr):
         from sumpy.kernel import AxisTargetDerivative
         return expr.copy(kernel=AxisTargetDerivative(self.ambient_axis, expr.kernel))
diff --git a/pytential/symbolic/matrix.py b/pytential/symbolic/matrix.py
index 9b4622fde5f4bf8cc0527b05b09c38433fdf4267..2012b9928c0dad7e10947ba110c5ca65e9041faa 100644
--- a/pytential/symbolic/matrix.py
+++ b/pytential/symbolic/matrix.py
@@ -183,11 +183,13 @@ class MatrixBuilder(EvaluationMapperBase):
 
         assert target_discr is source.density_discr
 
+        from pytential.qbx.utils import get_centers_on_side
+
         assert abs(expr.qbx_forced_limit) > 0
         _, (mat,) = mat_gen(self.queue,
                 target_discr.nodes(),
                 source.fine_density_discr.nodes(),
-                source.centers(expr.qbx_forced_limit),
+                get_centers_on_side(source, expr.qbx_forced_limit),
                 **kernel_args)
 
         mat = mat.get()
diff --git a/pytential/symbolic/pde/cahn_hilliard.py b/pytential/symbolic/pde/cahn_hilliard.py
new file mode 100644
index 0000000000000000000000000000000000000000..fd6ec4824cc107fb5d4ea4de8520997f59557ca5
--- /dev/null
+++ b/pytential/symbolic/pde/cahn_hilliard.py
@@ -0,0 +1,106 @@
+from __future__ import division, absolute_import, print_function
+
+__copyright__ = "Copyright (C) 2017 Andreas Kloeckner"
+
+__license__ = """
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+"""
+
+
+__doc__ = """
+.. autoclass:: CahnHilliardOperator
+"""
+
+import numpy as np
+from pytential.symbolic.pde.scalar import L2WeightedPDEOperator
+from pytential import sym
+from functools import partial
+
+
+class CahnHilliardOperator(L2WeightedPDEOperator):
+    def __init__(self, lambda1, lambda2, c):
+        self.lambdas = (lambda1, lambda2)
+        self.c = c
+
+    def make_unknown(self, name):
+        return sym.make_sym_vector(name, 2)
+
+    def S_G(self, i, density, qbx_forced_limit, op_map=None):  # noqa: N802
+        if op_map is None:
+            op_map = lambda x: x  # noqa: E731
+
+        from sumpy.kernel import HelmholtzKernel
+        hhk = HelmholtzKernel(2, allow_evanescent=True)
+        hhk_scaling = 1j/4
+
+        if i == 0:
+            lam1, lam2 = self.lambdas
+            return (
+                    # FIXME: Verify scaling
+                    -1/(2*np.pi*(lam1**2-lam2**2)) / hhk_scaling
+                    *
+                    (
+                        op_map(sym.S(hhk, density, k=1j*lam1,
+                            qbx_forced_limit=qbx_forced_limit))
+                        -
+                        op_map(sym.S(hhk, density, k=1j*lam2,
+                            qbx_forced_limit=qbx_forced_limit))))
+        else:
+            return (
+                    # FIXME: Verify scaling
+
+                    -1/(2*np.pi) / hhk_scaling
+                    * op_map(sym.S(hhk, density, k=1j*self.lambdas[i-1],
+                        qbx_forced_limit=qbx_forced_limit)))
+
+    def representation(self, unknown):
+        sig1, sig2 = unknown
+        S_G = partial(self.S_G, qbx_forced_limit=None)  # noqa: N806
+
+        return S_G(1, sig1) + S_G(0, sig2)
+
+    def operator(self, unknown):
+        sig1, sig2 = unknown
+        lam1, lam2 = self.lambdas
+        S_G = partial(self.S_G, qbx_forced_limit=1)  # noqa: N806
+
+        c = self.c
+
+        def Sn_G(i, density):  # noqa
+            return self.S_G(i, density,
+                        qbx_forced_limit="avg",
+                        op_map=partial(sym.normal_derivative, 2))
+
+        d = sym.make_obj_array([
+            0.5*sig1,
+            0.5*lam2**2*sig1 - 0.5*sig2
+            ])
+        a = sym.make_obj_array([
+            # A11
+            Sn_G(1, sig1) + c*S_G(1, sig1)
+            # A12
+            + Sn_G(0, sig2) + c*S_G(0, sig2),
+
+            # A21
+            lam2**2*Sn_G(1, sig1)
+            # A22
+            - Sn_G(1, sig2) + lam1**2*Sn_G(0, sig2)
+            ])
+
+        return d+a
diff --git a/pytential/symbolic/primitives.py b/pytential/symbolic/primitives.py
index 57b3ff97cc0dcdb9469deb46b59a5b8ae154f14a..bcf9400885c3d45d4c6bc02b538c27902d4b5685 100644
--- a/pytential/symbolic/primitives.py
+++ b/pytential/symbolic/primitives.py
@@ -84,6 +84,12 @@ Elementary numerics
 Calculus
 ^^^^^^^^
 .. autoclass:: Derivative
+.. autofunction:: dd_axis
+.. autofunction:: d_dx
+.. autofunction:: d_dy
+.. autofunction:: d_dz
+.. autofunction:: grad_mv
+.. autofunction:: grad
 
 Layer potentials
 ^^^^^^^^^^^^^^^^
@@ -452,6 +458,9 @@ class Derivative(DerivativeBase):
 
 
 def dd_axis(axis, ambient_dim, operand):
+    """Return the derivative along (XYZ) axis *axis*
+    (in *ambient_dim*-dimensional space) of *operand*.
+    """
     d = Derivative()
 
     unit_vector = np.zeros(ambient_dim)
@@ -469,6 +478,23 @@ d_dy = partial(dd_axis, 1)
 d_dz = partial(dd_axis, 2)
 
 
+def grad_mv(ambient_dim, operand):
+    """Return the *ambient_dim*-dimensional gradient of
+    *operand* as a :class:`pymbolic.geometric_algebra.MultiVector`.
+    """
+
+    d = Derivative()
+    return d.resolve(
+            d.dnabla(ambient_dim) * d(operand))
+
+
+def grad(ambient_dim, operand):
+    """Return the *ambient_dim*-dimensional gradient of
+    *operand* as a :class:`numpy.ndarray`.
+    """
+    return grad_mv(ambient_dim, operand).as_vector()
+
+
 # {{{ potentials
 
 def hashable_kernel_args(kernel_arguments):
diff --git a/requirements.txt b/requirements.txt
index 0c3cc71e0819605fe98f5d4bc63c44ad3fcfaff5..b8bf37d218305231a886bb54047db0d56d9d4bf3 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,10 +1,10 @@
 numpy
 git+git://github.com/inducer/pymbolic
 sympy==1.0
-git+git://github.com/inducer/modepy
-git+git://github.com/pyopencl/pyopencl
-git+git://github.com/inducer/islpy
-git+git://github.com/inducer/loopy
-git+git://github.com/inducer/boxtree
-git+git://github.com/inducer/meshmode
-git+git://github.com/inducer/sumpy
+git+https://github.com/inducer/modepy
+git+https://github.com/pyopencl/pyopencl
+git+https://github.com/inducer/islpy
+git+https://github.com/inducer/loopy
+git+https://github.com/inducer/boxtree
+git+https://github.com/inducer/meshmode
+git+https://github.com/inducer/sumpy
diff --git a/test/test_global_qbx.py b/test/test_global_qbx.py
index d7e514510d320602496a8b13a562a9da99733eba..7326efc13d212be30c83adecedc7a3e579722605 100644
--- a/test/test_global_qbx.py
+++ b/test/test_global_qbx.py
@@ -39,7 +39,7 @@ from pytential.qbx import QBXLayerPotentialSource
 from functools import partial
 from meshmode.mesh.generation import (  # noqa
         ellipse, cloverleaf, starfish, drop, n_gon, qbx_peanut,
-        make_curve_mesh)
+        make_curve_mesh, generate_icosphere, generate_torus)
 from extra_curve_data import horseshoe
 
 
@@ -53,134 +53,161 @@ RNG_SEED = 10
 FAR_TARGET_DIST_FROM_SOURCE = 10
 
 
-# {{{ utilities for iterating over panels
+# {{{ source refinement checker
 
 class ElementInfo(RecordWithoutPickling):
     """
     .. attribute:: element_nr
-    .. attribute:: neighbors
     .. attribute:: discr_slice
-    .. attribute:: mesh_slice
-    .. attribute:: element_group
-    .. attribute:: mesh_element_group
     """
     __slots__ = ["element_nr",
-                 "neighbors",
                  "discr_slice"]
 
 
 def iter_elements(discr):
     discr_nodes_idx = 0
     element_nr = 0
-    adjacency = discr.mesh.nodal_adjacency
 
     for discr_group in discr.groups:
         start = element_nr
         for element_nr in range(start, start + discr_group.nelements):
             yield ElementInfo(
                 element_nr=element_nr,
-                neighbors=list(adjacency.neighbors[
-                    slice(*adjacency.neighbors_starts[
-                        element_nr:element_nr+2])]),
                 discr_slice=slice(discr_nodes_idx,
                    discr_nodes_idx + discr_group.nunit_nodes))
 
             discr_nodes_idx += discr_group.nunit_nodes
 
-# }}}
-
 
-@pytest.mark.parametrize(("curve_name", "curve_f", "nelements"), [
-    ("20-to-1 ellipse", partial(ellipse, 20), 100),
-    ("horseshoe", horseshoe, 64),
-    ])
-def test_source_refinement(ctx_getter, curve_name, curve_f, nelements):
+def run_source_refinement_test(ctx_getter, mesh, order, helmholtz_k=None):
     cl_ctx = ctx_getter()
     queue = cl.CommandQueue(cl_ctx)
 
-    # {{{ generate lpot source, run refiner
-
-    order = 16
-    helmholtz_k = 10
-
-    mesh = make_curve_mesh(curve_f, np.linspace(0, 1, nelements+1), order)
-
     from meshmode.discretization import Discretization
-    from meshmode.discretization.poly_element import \
-            InterpolatoryQuadratureSimplexGroupFactory
+    from meshmode.discretization.poly_element import (
+            InterpolatoryQuadratureSimplexGroupFactory,
+            QuadratureSimplexGroupFactory)
+
     factory = InterpolatoryQuadratureSimplexGroupFactory(order)
+    fine_factory = QuadratureSimplexGroupFactory(4 * order)
 
     discr = Discretization(cl_ctx, mesh, factory)
 
-    from pytential.qbx.refinement import QBXLayerPotentialSourceRefiner
+    from pytential.qbx.refinement import (
+            RefinerCodeContainer, refine_for_global_qbx)
 
     lpot_source = QBXLayerPotentialSource(discr, order)
     del discr
-    refiner = QBXLayerPotentialSourceRefiner(cl_ctx)
 
-    lpot_source, conn = refiner(lpot_source, factory, helmholtz_k)
+    refiner_extra_kwargs = {}
+    if helmholtz_k is not None:
+        refiner_extra_kwargs["kernel_length_scale"] = 5/helmholtz_k
+
+    lpot_source, conn = refine_for_global_qbx(
+            lpot_source, RefinerCodeContainer(cl_ctx),
+            factory, fine_factory, **refiner_extra_kwargs)
+
+    from pytential.qbx.utils import get_centers_on_side
 
     discr_nodes = lpot_source.density_discr.nodes().get(queue)
-    int_centers = lpot_source.centers(-1)
+    fine_discr_nodes = lpot_source.fine_density_discr.nodes().get(queue)
+    int_centers = get_centers_on_side(lpot_source, -1)
     int_centers = np.array([axis.get(queue) for axis in int_centers])
-    ext_centers = lpot_source.centers(+1)
+    ext_centers = get_centers_on_side(lpot_source, +1)
     ext_centers = np.array([axis.get(queue) for axis in ext_centers])
-    panel_sizes = lpot_source.panel_sizes("npanels").get(queue)
-
-    # }}}
+    expansion_radii = lpot_source._expansion_radii("npanels").get(queue)
+    panel_sizes = lpot_source._panel_sizes("npanels").get(queue)
+    fine_panel_sizes = lpot_source._fine_panel_sizes("npanels").get(queue)
 
     # {{{ check if satisfying criteria
 
-    def check_panel(panel):
-        # Check 2-to-1 panel to neighbor size ratio.
-        for neighbor in panel.neighbors:
-            assert panel_sizes[panel.element_nr] / panel_sizes[neighbor] <= 2, \
-                (panel_sizes[panel.element_nr], panel_sizes[neighbor])
-
-        # Check wavenumber to panel size ratio.
-        assert panel_sizes[panel.element_nr] * helmholtz_k <= 5
-
-    def check_panel_pair(panel_1, panel_2):
-        h_1 = panel_sizes[panel_1.element_nr]
-        h_2 = panel_sizes[panel_2.element_nr]
-
-        if panel_1.element_nr == panel_2.element_nr:
+    def check_disk_undisturbed_by_sources(centers_panel, sources_panel):
+        if centers_panel.element_nr == sources_panel.element_nr:
             # Same panel
             return
 
-        panel_1_centers = int_centers[:, panel_1.discr_slice]
-        panel_2_nodes = discr_nodes[:, panel_2.discr_slice]
+        my_int_centers = int_centers[:, centers_panel.discr_slice]
+        my_ext_centers = ext_centers[:, centers_panel.discr_slice]
+        all_centers = np.append(my_int_centers, my_ext_centers, axis=-1)
+
+        nodes = discr_nodes[:, sources_panel.discr_slice]
 
         # =distance(centers of panel 1, panel 2)
         dist = (
             la.norm((
-                    panel_1_centers[..., np.newaxis] -
-                    panel_2_nodes[:, np.newaxis, ...]).T,
+                    all_centers[..., np.newaxis] -
+                    nodes[:, np.newaxis, ...]).T,
                 axis=-1)
             .min())
 
-        # Criterion 1:
+        # Criterion:
         # A center cannot be closer to another panel than to its originating
         # panel.
 
-        assert dist >= h_1 / 2, (dist, h_1, panel_1.element_nr, panel_2.element_nr)
+        rad = expansion_radii[centers_panel.element_nr]
+        assert dist >= rad, \
+                (dist, rad, centers_panel.element_nr, sources_panel.element_nr)
 
-        # Criterion 2:
-        # A center cannot be closer to another panel than that panel's
-        # centers - unless the panels are adjacent, to allow for refinement.
+    def check_sufficient_quadrature_resolution(centers_panel, sources_panel):
+        h = fine_panel_sizes[sources_panel.element_nr]
 
-        if panel_2.element_nr in panel_1.neighbors:
-            return
+        my_int_centers = int_centers[:, centers_panel.discr_slice]
+        my_ext_centers = ext_centers[:, centers_panel.discr_slice]
+        all_centers = np.append(my_int_centers, my_ext_centers, axis=-1)
 
-        assert dist >= h_2 / 2, (dist, h_2, panel_1.element_nr, panel_2.element_nr)
+        nodes = fine_discr_nodes[:, sources_panel.discr_slice]
 
-    for panel_1 in iter_elements(lpot_source.density_discr):
-        check_panel(panel_1)
+        # =distance(centers of panel 1, panel 2)
+        dist = (
+            la.norm((
+                    all_centers[..., np.newaxis] -
+                    nodes[:, np.newaxis, ...]).T,
+                axis=-1)
+            .min())
+
+        # Criterion:
+        # The quadrature contribution from each panel is as accurate
+        # as from the center's own source panel.
+        assert dist >= h / 4, \
+                (dist, h, centers_panel.element_nr, sources_panel.element_nr)
+
+    def check_panel_size_to_helmholtz_k_ratio(panel):
+        # Check wavenumber to panel size ratio.
+        assert panel_sizes[panel.element_nr] * helmholtz_k <= 5
+
+    for i, panel_1 in enumerate(iter_elements(lpot_source.density_discr)):
         for panel_2 in iter_elements(lpot_source.density_discr):
-            check_panel_pair(panel_1, panel_2)
+            check_disk_undisturbed_by_sources(panel_1, panel_2)
+        for panel_2 in iter_elements(lpot_source.fine_density_discr):
+            check_sufficient_quadrature_resolution(panel_1, panel_2)
+        if helmholtz_k is not None:
+            check_panel_size_to_helmholtz_k_ratio(panel_1)
 
     # }}}
 
+# }}}
+
+
+@pytest.mark.parametrize(("curve_name", "curve_f", "nelements"), [
+    ("20-to-1 ellipse", partial(ellipse, 20), 100),
+    ("horseshoe", horseshoe, 64),
+    ])
+def test_source_refinement_2d(ctx_getter, curve_name, curve_f, nelements):
+    helmholtz_k = 10
+    order = 8
+
+    mesh = make_curve_mesh(curve_f, np.linspace(0, 1, nelements+1), order)
+    run_source_refinement_test(ctx_getter, mesh, order, helmholtz_k)
+
+
+@pytest.mark.parametrize(("surface_name", "surface_f", "order"), [
+    ("sphere", partial(generate_icosphere, 1), 4),
+    ("torus", partial(generate_torus, 3, 1, n_inner=10, n_outer=7), 6),
+    ])
+def test_source_refinement_3d(ctx_getter, surface_name, surface_f, order):
+    mesh = surface_f(order=order)
+    run_source_refinement_test(ctx_getter, mesh, order)
+
 
 @pytest.mark.parametrize(("curve_name", "curve_f", "nelements"), [
     ("20-to-1 ellipse", partial(ellipse, 20), 100),
@@ -204,16 +231,13 @@ def test_target_association(ctx_getter, curve_name, curve_f, nelements):
 
     discr = Discretization(cl_ctx, mesh, factory)
 
-    from pytential.qbx.refinement import QBXLayerPotentialSourceRefiner
-
-    lpot_source = QBXLayerPotentialSource(discr, order)
+    lpot_source, conn = QBXLayerPotentialSource(discr, order).with_refinement()
     del discr
-    refiner = QBXLayerPotentialSourceRefiner(cl_ctx)
 
-    lpot_source, conn = refiner(lpot_source, factory)
+    from pytential.qbx.utils import get_centers_on_side
 
-    int_centers = lpot_source.centers(-1)
-    ext_centers = lpot_source.centers(+1)
+    int_centers = get_centers_on_side(lpot_source, -1)
+    ext_centers = get_centers_on_side(lpot_source, +1)
 
     # }}}
 
@@ -223,7 +247,8 @@ def test_target_association(ctx_getter, curve_name, curve_f, nelements):
     rng = PhiloxGenerator(cl_ctx, seed=RNG_SEED)
     nsources = lpot_source.density_discr.nnodes
     noise = rng.uniform(queue, nsources, dtype=np.float, a=0.01, b=1.0)
-    panel_sizes = lpot_source.panel_sizes("nsources").with_queue(queue)
+    tunnel_radius = \
+            lpot_source._close_target_tunnel_radius("nsources").with_queue(queue)
 
     def targets_from_sources(sign, dist):
         from pytential import sym, bind
@@ -234,8 +259,8 @@ def test_target_association(ctx_getter, curve_name, curve_f, nelements):
 
     from pytential.target import PointsTarget
 
-    int_targets = PointsTarget(targets_from_sources(-1, noise * panel_sizes / 2))
-    ext_targets = PointsTarget(targets_from_sources(+1, noise * panel_sizes / 2))
+    int_targets = PointsTarget(targets_from_sources(-1, noise * tunnel_radius))
+    ext_targets = PointsTarget(targets_from_sources(+1, noise * tunnel_radius))
     far_targets = PointsTarget(targets_from_sources(+1, FAR_TARGET_DIST_FROM_SOURCE))
 
     # Create target discretizations.
@@ -270,7 +295,7 @@ def test_target_association(ctx_getter, curve_name, curve_f, nelements):
         QBXTargetAssociator(cl_ctx)(lpot_source, target_discrs)
         .get(queue=queue))
 
-    panel_sizes = lpot_source.panel_sizes("nsources").get(queue)
+    expansion_radii = lpot_source._expansion_radii("nsources").get(queue)
 
     int_centers = np.array([axis.get(queue) for axis in int_centers])
     ext_centers = np.array([axis.get(queue) for axis in ext_centers])
@@ -290,7 +315,7 @@ def test_target_association(ctx_getter, curve_name, curve_f, nelements):
                             target_to_source_result, target_to_side_result):
         assert (target_to_side_result == true_side).all()
         dists = la.norm((targets.T - centers.T[target_to_source_result]), axis=1)
-        assert (dists <= panel_sizes[target_to_source_result] / 2).all()
+        assert (dists <= expansion_radii[target_to_source_result]).all()
 
     # Checks that far targets are not assigned a center.
     def check_far_targets(target_to_source_result):
diff --git a/test/test_layer_pot.py b/test/test_layer_pot.py
index c67b2e5c5012d53242fa245e1128c1798cebe8c3..6e8a81000b31958f228615c17c9d0919221a7d41 100644
--- a/test/test_layer_pot.py
+++ b/test/test_layer_pot.py
@@ -34,7 +34,7 @@ from pyopencl.tools import (  # noqa
 
 from functools import partial
 from meshmode.mesh.generation import (  # noqa
-        ellipse, cloverleaf, starfish, drop, n_gon, qbx_peanut,
+        ellipse, cloverleaf, starfish, drop, n_gon, qbx_peanut, WobblyCircle,
         make_curve_mesh)
 from sumpy.visualization import FieldPlotter
 from pytential import bind, sym, norm
@@ -160,7 +160,10 @@ def test_ellipse_eigenvalues(ctx_getter, ellipse_aspect, mode_nr, qbx_order):
 
         if 0:
             # plot geometry, centers, normals
-            centers = qbx.centers(density_discr, 1)
+
+            from pytential.qbx.utils import get_centers_on_side
+            centers = get_centers_on_side(qbx, 1)
+
             nodes_h = nodes.get()
             centers_h = [centers[0].get(), centers[1].get()]
             pt.plot(nodes_h[0], nodes_h[1], "x-")
@@ -309,10 +312,15 @@ def run_int_eq_test(
     if source_order is None:
         source_order = 4*target_order
 
+    refiner_extra_kwargs = {}
+
+    if k != 0:
+        refiner_extra_kwargs["kernel_length_scale"] = 5/k
+
     qbx, _ = QBXLayerPotentialSource(
             pre_density_discr, fine_order=source_order, qbx_order=qbx_order,
             # Don't use FMM for now
-            fmm_order=False).with_refinement()
+            fmm_order=False).with_refinement(**refiner_extra_kwargs)
 
     density_discr = qbx.density_discr
 
@@ -694,6 +702,13 @@ def get_starfish_mesh(refinement_increment, target_order):
                 target_order)
 
 
+def get_wobbly_circle_mesh(refinement_increment, target_order):
+    nelements = [3000, 5000, 7000][refinement_increment]
+    return make_curve_mesh(WobblyCircle.random(30, seed=30),
+                np.linspace(0, 1, nelements+1),
+                target_order)
+
+
 def get_sphere_mesh(refinement_increment, target_order):
     from meshmode.mesh.generation import generate_icosphere
     mesh = generate_icosphere(1, target_order)
@@ -801,11 +816,17 @@ def test_identities(ctx_getter, zero_op_name, mesh_name, mesh_getter, qbx_order,
             # FIXME: FMM kernel generation slow
             direct_eval = (k != 0)
 
+        refiner_extra_kwargs = {}
+
+        if k != 0:
+            refiner_extra_kwargs["kernel_length_scale"] = 5/k
+
         qbx, _ = QBXLayerPotentialSource(
                 pre_density_discr, 4*target_order,
                 qbx_order, fmm_order=(
                     False if direct_eval else qbx_order + order_bump)
-                ).with_refinement()
+                ).with_refinement(**refiner_extra_kwargs)
+
         density_discr = qbx.density_discr
 
         # {{{ compute values of a solution to the PDE
@@ -903,8 +924,12 @@ def test_off_surface_eval(ctx_getter, use_fmm, do_plot=False):
 
     pre_density_discr = Discretization(
             cl_ctx, mesh, InterpolatoryQuadratureSimplexGroupFactory(target_order))
-    qbx, _ = QBXLayerPotentialSource(pre_density_discr, 4*target_order, qbx_order,
-            fmm_order=fmm_order).with_refinement()
+    qbx, _ = QBXLayerPotentialSource(
+            pre_density_discr,
+            4*target_order,
+            qbx_order,
+            fmm_order=fmm_order,
+            ).with_refinement()
 
     density_discr = qbx.density_discr
 
@@ -919,10 +944,11 @@ def test_off_surface_eval(ctx_getter, use_fmm, do_plot=False):
             (qbx, PointsTarget(fplot.points)),
             op)(queue, sigma=sigma)
 
-    print(fld_in_vol)
-
     err = cl.clmath.fabs(fld_in_vol - (-1))
 
+    linf_err = cl.array.max(err).get()
+    print("l_inf error:", linf_err)
+
     if do_plot:
         fplot.show_scalar_in_matplotlib(fld_in_vol.get())
         import matplotlib.pyplot as pt
@@ -930,7 +956,86 @@ def test_off_surface_eval(ctx_getter, use_fmm, do_plot=False):
         pt.show()
 
     # FIXME: Why does the FMM only meet this sloppy tolerance?
-    assert (err < 1e-2).get().all()
+    assert linf_err < 1e-2
+
+# }}}
+
+
+# {{{ test off-surface eval vs direct
+
+def test_off_surface_eval_vs_direct(ctx_getter,  do_plot=False):
+    logging.basicConfig(level=logging.INFO)
+
+    cl_ctx = ctx_getter()
+    queue = cl.CommandQueue(cl_ctx)
+
+    # prevent cache 'splosion
+    from sympy.core.cache import clear_cache
+    clear_cache()
+
+    nelements = 300
+    target_order = 8
+    qbx_order = 3
+
+    mesh = make_curve_mesh(WobblyCircle.random(8, seed=30),
+                np.linspace(0, 1, nelements+1),
+                target_order)
+
+    from pytential.qbx import QBXLayerPotentialSource
+    from meshmode.discretization import Discretization
+    from meshmode.discretization.poly_element import \
+            InterpolatoryQuadratureSimplexGroupFactory
+
+    pre_density_discr = Discretization(
+            cl_ctx, mesh, InterpolatoryQuadratureSimplexGroupFactory(target_order))
+    direct_qbx, _ = QBXLayerPotentialSource(
+            pre_density_discr, 4*target_order, qbx_order,
+            fmm_order=False,
+            target_stick_out_factor=0.05,
+            ).with_refinement()
+    fmm_qbx, _ = QBXLayerPotentialSource(
+            pre_density_discr, 4*target_order, qbx_order,
+            fmm_order=qbx_order + 3,
+            expansion_disks_in_tree_have_extent=True,
+            target_stick_out_factor=0.05,
+            ).with_refinement()
+
+    fplot = FieldPlotter(np.zeros(2), extent=5, npoints=1000)
+    from pytential.target import PointsTarget
+    ptarget = PointsTarget(fplot.points)
+    from sumpy.kernel import LaplaceKernel
+
+    op = sym.D(LaplaceKernel(2), sym.var("sigma"), qbx_forced_limit=None)
+
+    from pytential.qbx import QBXTargetAssociationFailedException
+    try:
+        direct_density_discr = direct_qbx.density_discr
+        direct_sigma = direct_density_discr.zeros(queue) + 1
+        direct_fld_in_vol = bind((direct_qbx, ptarget), op)(
+                queue, sigma=direct_sigma)
+
+    except QBXTargetAssociationFailedException as e:
+        fplot.show_scalar_in_matplotlib(e.failed_target_flags.get(queue))
+        import matplotlib.pyplot as pt
+        pt.show()
+        raise
+
+    fmm_density_discr = fmm_qbx.density_discr
+    fmm_sigma = fmm_density_discr.zeros(queue) + 1
+    fmm_fld_in_vol = bind((fmm_qbx, ptarget), op)(queue, sigma=fmm_sigma)
+
+    err = cl.clmath.fabs(fmm_fld_in_vol - direct_fld_in_vol)
+
+    linf_err = cl.array.max(err).get()
+    print("l_inf error:", linf_err)
+
+    if do_plot:
+        #fplot.show_scalar_in_mayavi(0.1*cl.clmath.log10(1e-15 + err).get(queue))
+        fplot.show_scalar_in_mayavi(fmm_fld_in_vol.get(queue))
+        import mayavi.mlab as mlab
+        mlab.show()
+
+    assert linf_err < 1e-3
 
 # }}}
 
diff --git a/test/test_muller.py b/test/test_muller.py
index 30d1a47d8564f607a125bdfd0df5f6e67f811606..fb23e911d32c1c720a3284004014cb83088d75d7 100644
--- a/test/test_muller.py
+++ b/test/test_muller.py
@@ -37,10 +37,14 @@ def test_muller(true_roots):
     :arg n: number of zeros sought
     :return: (roots, niter)
     """
+    np.random.seed(15)
+    z_start = np.random.rand(3) + 1j*np.random.rand(3)
+
     eps = 1e-12
     from pytential.muller import muller_deflate
     roots, niter = muller_deflate(
-        lambda z: poly_with_roots(z, true_roots), len(true_roots))
+        lambda z: poly_with_roots(z, true_roots), len(true_roots),
+        z_start=z_start)
 
     for r_i in roots:
         min_dist, true_root = min(