diff --git a/doc/source/tutorial/kpm.rst b/doc/source/tutorial/kpm.rst
index 38c84891cbd84ea044e8600ee56c3e3d0a1cd638..767423f32d899915d846f6ab3594c904ff437a1b 100644
--- a/doc/source/tutorial/kpm.rst
+++ b/doc/source/tutorial/kpm.rst
@@ -17,8 +17,27 @@ KPM method `kwant.kpm`, that is based on the algorithms presented in Ref. [1]_.
 
 .. seealso::
     The complete source code of this example can be found in
-    :download:`kernel_polynomial_method.py </code/download/kernel_polynomial_method.py>`
+    :jupyter-download:script:`kernel_polynomial_method`
 
+.. jupyter-kernel::
+    :id: kernel_polynomial_method
+
+.. jupyter-execute::
+    :hide-code:
+
+    # Tutorial 2.8. Calculating spectral density with the Kernel Polynomial Method
+    # ============================================================================
+    #
+    # Physics background
+    # ------------------
+    #  - Chebyshev polynomials, random trace approximation, spectral densities.
+    #
+    # Kwant features highlighted
+    # --------------------------
+    #  - kpm module,kwant operators.
+
+    import scipy
+    from matplotlib import pyplot
 
 Introduction
 ************
@@ -103,35 +122,104 @@ to obtain the (global) density of states of a graphene disk.
 
 We start by importing kwant and defining our system.
 
-.. literalinclude:: /code/include/kernel_polynomial_method.py
-    :start-after: #HIDDEN_BEGIN_sys1
-    :end-before: #HIDDEN_END_sys1
+.. jupyter-execute::
+
+    # necessary imports
+    import kwant
+    import numpy as np
+
+
+    # define the system
+    def make_syst(r=30, t=-1, a=1):
+        syst = kwant.Builder()
+        lat = kwant.lattice.honeycomb(a, norbs=1)
+
+        def circle(pos):
+            x, y = pos
+            return x ** 2 + y ** 2 < r ** 2
+
+        syst[lat.shape(circle, (0, 0))] = 0.
+        syst[lat.neighbors()] = t
+        syst.eradicate_dangling()
+
+        return syst
+
+.. jupyter-execute::
+    :hide-code:
+
+    ## common plotting routines ##
+
+    # Plot several density of states curves on the same axes.
+    def plot_dos(labels_to_data):
+        pyplot.figure(figsize=(5,4))
+        for label, (x, y) in labels_to_data:
+            pyplot.plot(x, y.real, label=label, linewidth=2)
+        pyplot.legend(loc=2, framealpha=0.5)
+        pyplot.xlabel("energy [t]")
+        pyplot.ylabel("DoS [a.u.]")
+        pyplot.show()
+
+
+    # Plot fill density of states plus curves on the same axes.
+    def plot_dos_and_curves(dos, labels_to_data):
+        pyplot.figure(figsize=(5,4))
+        pyplot.fill_between(dos[0], dos[1], label="DoS [a.u.]",
+                         alpha=0.5, color='gray')
+        for label, (x, y) in labels_to_data:
+            pyplot.plot(x, y, label=label, linewidth=2)
+        pyplot.legend(loc=2, framealpha=0.5)
+        pyplot.xlabel("energy [t]")
+        pyplot.ylabel("$σ [e^2/h]$")
+        pyplot.show()
+
+
+    def site_size_conversion(densities):
+        return 3 * np.abs(densities) / max(densities)
+
+
+    # Plot several local density of states maps in different subplots
+    def plot_ldos(syst, densities):
+        fig, axes = pyplot.subplots(1, len(densities), figsize=(7*len(densities), 7))
+        for ax, (title, rho) in zip(axes, densities):
+            kwant.plotter.density(syst, rho.real, ax=ax)
+            ax.set_title(title)
+            ax.set(adjustable='box', aspect='equal')
+        pyplot.show()
 
 After making a system we can then create a `~kwant.kpm.SpectralDensity`
 object that represents the density of states for this system.
 
-.. literalinclude:: /code/include/kernel_polynomial_method.py
-    :start-after: #HIDDEN_BEGIN_kpm1
-    :end-before: #HIDDEN_END_kpm1
+.. jupyter-execute::
+
+    fsyst = make_syst().finalized()
+
+    spectrum = kwant.kpm.SpectralDensity(fsyst)
 
 The `~kwant.kpm.SpectralDensity` can then be called like a function to obtain a
 sequence of energies in the spectrum of the Hamiltonian, and the corresponding
 density of states at these energies.
 
-.. literalinclude:: /code/include/kernel_polynomial_method.py
-    :start-after: #HIDDEN_BEGIN_kpm2
-    :end-before: #HIDDEN_END_kpm2
+.. jupyter-execute::
+
+    energies, densities = spectrum()
 
 When called with no arguments, an optimal set of energies is chosen (these are
 not evenly distributed over the spectrum, see Ref. [1]_ for details), however
 it is also possible to provide an explicit sequence of energies at which to
 evaluate the density of states.
 
-.. literalinclude:: /code/include/kernel_polynomial_method.py
-    :start-after: #HIDDEN_BEGIN_kpm3
-    :end-before: #HIDDEN_END_kpm3
+.. jupyter-execute::
+
+    energy_subset = np.linspace(0, 2)
+    density_subset = spectrum(energy_subset)
+
+.. jupyter-execute::
+    :hide-code:
 
-.. image:: /code/figure/kpm_dos.*
+    plot_dos([
+        ('densities', (energies, densities)),
+        ('density subset', (energy_subset, density_subset)),
+    ])
 
 In addition to being called like functions, `~kwant.kpm.SpectralDensity`
 objects also have a method `~kwant.kpm.SpectralDensity.integrate` which can be
@@ -139,22 +227,21 @@ used to integrate the density of states against some distribution function over
 the whole spectrum. If no distribution function is specified, then the uniform
 distribution is used:
 
-.. literalinclude:: /code/include/kernel_polynomial_method.py
-    :start-after: #HIDDEN_BEGIN_int1
-    :end-before: #HIDDEN_END_int1
+.. jupyter-execute::
 
-.. literalinclude:: /code/figure/kpm_normalization.txt
+    print('identity resolution:', spectrum.integrate())
 
 We see that the integral of the density of states is normalized to the total
 number of available states in the system. If we wish to calculate, say, the
 number of states populated in equilibrium, then we should integrate with
 respect to a Fermi-Dirac distribution:
 
-.. literalinclude:: /code/include/kernel_polynomial_method.py
-    :start-after: #HIDDEN_BEGIN_int2
-    :end-before: #HIDDEN_END_int2
+.. jupyter-execute::
 
-.. literalinclude:: /code/figure/kpm_total_states.txt
+    # Fermi energy 0.1 and temperature 0.2
+    fermi = lambda E: 1 / (np.exp((E - 0.1) / 0.2) + 1)
+
+    print('number of filled states:', spectrum.integrate(fermi))
 
 .. specialnote:: Stability and performance: spectral bounds
 
@@ -194,23 +281,51 @@ In the following example, we compute the local density of states at the center
 of the graphene disk, and we add a staggering potential between the two
 sublattices.
 
-.. literalinclude:: /code/include/kernel_polynomial_method.py
-    :start-after: #HIDDEN_BEGIN_sys3
-    :end-before: #HIDDEN_END_sys3
+.. jupyter-execute::
+
+    def make_syst_staggered(r=30, t=-1, a=1, m=0.1):
+        syst = kwant.Builder()
+        lat = kwant.lattice.honeycomb(a, norbs=1)
+
+        def circle(pos):
+            x, y = pos
+            return x ** 2 + y ** 2 < r ** 2
+
+        syst[lat.a.shape(circle, (0, 0))] = m
+        syst[lat.b.shape(circle, (0, 0))] = -m
+        syst[lat.neighbors()] = t
+        syst.eradicate_dangling()
+
+        return syst
 
 Next, we choose one site of each sublattice "A" and "B",
 
-.. literalinclude:: /code/include/kernel_polynomial_method.py
-    :start-after: #HIDDEN_BEGIN_op5
-    :end-before: #HIDDEN_END_op5
+.. jupyter-execute::
+
+    fsyst_staggered = make_syst_staggered().finalized()
+    # find 'A' and 'B' sites in the unit cell at the center of the disk
+    center_tag = np.array([0, 0])
+    where = lambda s: s.tag == center_tag
+    # make local vectors
+    vector_factory = kwant.kpm.LocalVectors(fsyst_staggered, where)
 
 and plot their respective local density of states.
 
-.. literalinclude:: /code/include/kernel_polynomial_method.py
-    :start-after: #HIDDEN_BEGIN_op6
-    :end-before: #HIDDEN_END_op6
+.. jupyter-execute::
+
+    # 'num_vectors' can be unspecified when using 'LocalVectors'
+    local_dos = kwant.kpm.SpectralDensity(fsyst_staggered, num_vectors=None,
+                                          vector_factory=vector_factory,
+                                          mean=False)
+    energies, densities = local_dos()
 
-.. image:: /code/figure/kpm_ldos_sites.*
+.. jupyter-execute::
+    :hide-code:
+
+    plot_dos([
+        ('A sublattice', (energies, densities[:, 0])),
+        ('B sublattice', (energies, densities[:, 1])),
+    ])
 
 Note that there is no noise comming from the random vectors.
 
@@ -225,9 +340,15 @@ exactly is changed.
 The simplest way to obtain a more accurate solution is to use the
 ``add_moments`` method:
 
-.. literalinclude:: /code/include/kernel_polynomial_method.py
-    :start-after: #HIDDEN_BEGIN_acc1
-    :end-before: #HIDDEN_END_acc1
+.. jupyter-execute::
+    :hide-code:
+
+    spectrum = kwant.kpm.SpectralDensity(fsyst)
+    original_dos = spectrum()
+
+.. jupyter-execute::
+
+   spectrum.add_moments(energy_resolution=0.03)
 
 This will update the number of calculated moments and also the default
 number of sampling points such that the maximum distance between successive
@@ -237,12 +358,19 @@ Alternatively, you can directly increase the number of moments
 with ``add_moments``, or the number of random vectors with ``add_vectors``.
 The added vectors will be generated with the ``vector_factory``.
 
-.. literalinclude:: /code/include/kernel_polynomial_method.py
-    :start-after: #HIDDEN_BEGIN_acc2
-    :end-before: #HIDDEN_END_acc2
+.. jupyter-execute::
+
+    spectrum.add_moments(100)
+    spectrum.add_vectors(5)
 
-.. image:: /code/figure/kpm_dos_r.*
+.. jupyter-execute::
+    :hide-code:
 
+    increased_moments_dos = spectrum()
+    plot_dos([
+        ('density', original_dos),
+        ('higher number of moments', increased_moments_dos),
+    ])
 
 .. _operator_spectral_density:
 
@@ -264,17 +392,19 @@ the spectral density of the given operator that is calculated.
 If an explicit matrix is provided then it must have the same
 shape as the system Hamiltonian.
 
-.. literalinclude:: /code/include/kernel_polynomial_method.py
-    :start-after: #HIDDEN_BEGIN_op1
-    :end-before: #HIDDEN_END_op1
+.. jupyter-execute::
 
+    # identity matrix
+    matrix_op = scipy.sparse.eye(len(fsyst.sites))
+    matrix_spectrum = kwant.kpm.SpectralDensity(fsyst, operator=matrix_op)
 
 Or, to do the same calculation using `kwant.operator.Density`:
 
-.. literalinclude:: /code/include/kernel_polynomial_method.py
-    :start-after: #HIDDEN_BEGIN_op2
-    :end-before: #HIDDEN_END_op2
+.. jupyter-execute::
 
+    # 'sum=True' means we sum over all the sites
+    kwant_op = kwant.operator.Density(fsyst, sum=True)
+    operator_spectrum = kwant.kpm.SpectralDensity(fsyst, operator=kwant_op)
 
 Spectral density with random vectors
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -283,9 +413,11 @@ Using operators from `kwant.operator` allows us to calculate quantities
 such as the *local* density of states by telling the operator not to
 sum over all the sites of the system:
 
-.. literalinclude:: /code/include/kernel_polynomial_method.py
-    :start-after: #HIDDEN_BEGIN_op3
-    :end-before: #HIDDEN_END_op3
+.. jupyter-execute::
+
+    # 'sum=False' is the default, but we include it explicitly here for clarity.
+    kwant_op = kwant.operator.Density(fsyst, sum=False)
+    local_dos = kwant.kpm.SpectralDensity(fsyst, operator=kwant_op)
 
 `~kwant.kpm.SpectralDensity` will properly handle this vector output,
 and will average the local density obtained with random vectors.
@@ -294,12 +426,18 @@ The accuracy of this approximation depends on the number of random vectors used.
 This allows us to plot an approximate local density of states at different
 points in the spectrum:
 
-.. literalinclude:: /code/include/kernel_polynomial_method.py
-    :start-after: #HIDDEN_BEGIN_op4
-    :end-before: #HIDDEN_END_op4
+.. jupyter-execute::
 
-.. image:: /code/figure/kpm_ldos.*
+    zero_energy_ldos = local_dos(energy=0)
+    finite_energy_ldos = local_dos(energy=1)
 
+.. jupyter-execute::
+    :hide-code:
+
+    plot_ldos(fsyst, [
+        ('energy = 0', zero_energy_ldos),
+        ('energy = 1', finite_energy_ldos)
+    ])
 
 Calculating Kubo conductivity
 *****************************
@@ -325,17 +463,53 @@ with nearest neigbours hoppings. To turn it into a topological
 insulator we add imaginary second nearest neigbours hoppings, where
 the sign changes for each sublattice.
 
-.. literalinclude:: /code/include/kernel_polynomial_method.py
-    :start-after: #HIDDEN_BEGIN_sys2
-    :end-before: #HIDDEN_END_sys2
+.. jupyter-execute::
+
+    def make_syst_topo(r=30, a=1, t=1, t2=0.5):
+        syst = kwant.Builder()
+        lat = kwant.lattice.honeycomb(a, norbs=1, name=['a', 'b'])
+
+        def circle(pos):
+            x, y = pos
+            return x ** 2 + y ** 2 < r ** 2
+
+        syst[lat.shape(circle, (0, 0))] = 0.
+        syst[lat.neighbors()] = t
+        # add second neighbours hoppings
+        syst[lat.a.neighbors()] = 1j * t2
+        syst[lat.b.neighbors()] = -1j * t2
+        syst.eradicate_dangling()
+
+        return lat, syst.finalized()
 
 To calculate the bulk conductivity, we will select sites in the unit cell
 in the middle of the sample, and create a vector factory that outputs local
 vectors
 
-.. literalinclude:: /code/include/kernel_polynomial_method.py
-    :start-after: #HIDDEN_BEGIN_cond
-    :end-before: #HIDDEN_END_cond
+.. jupyter-execute::
+
+    # construct the Haldane model
+    lat, fsyst_topo = make_syst_topo()
+    # find 'A' and 'B' sites in the unit cell at the center of the disk
+    where = lambda s: np.linalg.norm(s.pos) < 1
+
+    # component 'xx'
+    s_factory = kwant.kpm.LocalVectors(fsyst_topo, where)
+    cond_xx = kwant.kpm.conductivity(fsyst_topo, alpha='x', beta='x', mean=True,
+                                     num_vectors=None, vector_factory=s_factory)
+    # component 'xy'
+    s_factory = kwant.kpm.LocalVectors(fsyst_topo, where)
+    cond_xy = kwant.kpm.conductivity(fsyst_topo, alpha='x', beta='y', mean=True,
+                                     num_vectors=None, vector_factory=s_factory)
+
+    energies = cond_xx.energies
+    cond_array_xx = np.array([cond_xx(e, temperature=0.01) for e in energies])
+    cond_array_xy = np.array([cond_xy(e, temperature=0.01) for e in energies])
+
+    # area of the unit cell per site
+    area_per_site = np.abs(np.cross(*lat.prim_vecs)) / len(lat.sublattices)
+    cond_array_xx /= area_per_site
+    cond_array_xy /= area_per_site
 
 Note that the Kubo conductivity must be normalized with the area covered
 by the vectors. In this case, each local vector represents a site, and
@@ -345,8 +519,22 @@ value of the conductivity over large parts of the system. In this
 case, the area that normalizes the result, is the area covered by
 the random vectors.
 
-.. image:: /code/figure/kpm_cond.*
+.. jupyter-execute::
+    :hide-code:
+
 
+    s_factory = kwant.kpm.LocalVectors(fsyst_topo, where)
+    spectrum = kwant.kpm.SpectralDensity(fsyst_topo, num_vectors=None,
+                                         vector_factory=s_factory)
+
+    plot_dos_and_curves(
+    (spectrum.energies, spectrum.densities * 8),
+    [
+        (r'Longitudinal conductivity $\sigma_{xx} / 4$',
+         (energies, cond_array_xx / 4)),
+        (r'Hall conductivity $\sigma_{xy}$',
+         (energies, cond_array_xy))],
+    )
 
 .. _advanced_topics:
 
@@ -370,9 +558,16 @@ To change how the random vectors are generated, you need only specify a
 function that takes the dimension of the Hilbert space as a single parameter,
 and which returns a vector in that Hilbert space:
 
-.. literalinclude:: /code/include/kernel_polynomial_method.py
-    :start-after: #HIDDEN_BEGIN_fact1
-    :end-before: #HIDDEN_END_fact1
+.. jupyter-execute::
+
+    # construct a generator of vectors with n random elements -1 or +1.
+    n = fsyst.hamiltonian_submatrix(sparse=True).shape[0]
+    def binary_vectors():
+        while True:
+           yield np.rint(np.random.random_sample(n)) * 2 - 1
+
+    custom_factory = kwant.kpm.SpectralDensity(fsyst,
+                                               vector_factory=binary_vectors())
 
 Aditionally, a `~kwant.kpm.LocalVectors` generator is also available, that
 returns local vectors that correspond to the sites passed. Note that
@@ -407,9 +602,16 @@ first argument, and linear in its second argument. Below, we compare two
 methods for computing the local density of states, one using
 `kwant.operator.Density`, and the other using a custom function.
 
-.. literalinclude:: /code/include/kernel_polynomial_method.py
-    :start-after: #HIDDEN_BEGIN_blm
-    :end-before: #HIDDEN_END_blm
+.. jupyter-execute::
+
+    rho = kwant.operator.Density(fsyst, sum=True)
+
+    # sesquilinear map that does the same thing as `rho`
+    def rho_alt(bra, ket):
+        return np.vdot(bra, ket)
+
+    rho_spectrum = kwant.kpm.SpectralDensity(fsyst, operator=rho)
+    rho_alt_spectrum = kwant.kpm.SpectralDensity(fsyst, operator=rho_alt)
 
 __ operator_spectral_density_