diff --git a/.travis.yml b/.travis.yml
index 9f9d3effab76d1da68a2cfc5e0100016c4949ab4..31ca2dbd28de07594d9d42c5b0a92fce5c6d5d04 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,8 +1,7 @@
 ######## Testing minimal compiler requirements ########
 # GCC          6.4.0
-# Clang        4.0
-# Apple Clang  900.0.39.2
-# Python       3.6
+# Clang        7.0
+# Apple Clang  1100.0.33.16
 #######################################################
 
 language: cpp
@@ -14,10 +13,10 @@ matrix:
 ## test gcc6 - single node/rank with threading backend ##
   - name: "osx, gcc, serial, py"
     os: osx
-    osx_image: xcode9.2
+    osx_image: xcode11.3
     python: 3.6
     env:
-      - MATRIX_EVAL="brew install gcc@6 && brew link --force --overwrite gcc@6 && CC=gcc-6 && CXX=g++-6"
+      - MATRIX_EVAL="brew install gcc@6 && brew link --force --overwrite gcc@6 && brew install cmake && CC=gcc-6 && CXX=g++-6"
       - BUILD_NAME=cthread-osx-gcc-py
       - WITH_DISTRIBUTED=serial WITH_PYTHON=true PY=3
     compiler: gcc-6
@@ -25,10 +24,10 @@ matrix:
 ## test gcc6 - mpi with threading backend ##
   - name: "osx, gcc, mpi, py"
     os: osx
-    osx_image: xcode9.2
+    osx_image: xcode11.3
     python: 3.6
     env:
-      - MATRIX_EVAL="brew install gcc@6 && brew link --force --overwrite gcc@6 && CC=gcc-6 && CXX=g++-6"
+      - MATRIX_EVAL="brew install gcc@6 && brew link --force --overwrite gcc@6 && brew install cmake && CC=gcc-6 && CXX=g++-6"
       - BUILD_NAME=mpi-osx-gcc-py
       - WITH_DISTRIBUTED=mpi WITH_PYTHON=true PY=3
     compiler: gcc-6
@@ -36,7 +35,7 @@ matrix:
 ## test clang9 - single node/rank with threading backend ##
   - name: "osx, apple clang, serial, py"
     os: osx
-    osx_image: xcode9.2
+    osx_image: xcode11.3
     python: 3.6
     env:
       - MATRIX_EVAL="CC=clang && CXX=clang++"
@@ -47,7 +46,7 @@ matrix:
 ## test clang9 - mpi with threading backend ##
   - name: "osx, apple clang, mpi, py"
     os: osx
-    osx_image: xcode9.2
+    osx_image: xcode11.3
     python: 3.6
     env:
       - MATRIX_EVAL="CC=clang && CXX=clang++"
@@ -59,12 +58,10 @@ matrix:
 ## test gcc6 - single node/rank with threading backend ##
   - name: "linux, gcc, serial, py"
     os: linux
-    dist: trusty
-    python: 3.6
+    dist: bionic
     addons:
       apt:
         sources:
-          - ubuntu-toolchain-r-test
         packages:
           - g++-6
           - openmpi-bin
@@ -78,12 +75,10 @@ matrix:
 ## test gcc6 - mpi with threading backend ##
   - name: "linux, gcc, mpi, py"
     os: linux
-    dist: trusty
-    python: 3.6
+    dist: bionic
     addons:
       apt:
         sources:
-          - ubuntu-toolchain-r-test
         packages:
           - g++-6
           - openmpi-bin
@@ -97,20 +92,15 @@ matrix:
 ## test clang4 - single node/rank with threading backend ##
   - name: "linux, clang, serial, py"
     os: linux
-    dist: trusty
-    python: 3.6
+    dist: bionic
     addons:
       apt:
         sources:
-          - ubuntu-toolchain-r-test
-          - llvm-toolchain-trusty-4.0
         packages:
-          - clang-4.0
-          - g++-6
           - openmpi-bin
           - libopenmpi-dev
     env:
-      - MATRIX_EVAL="CC=clang-4.0 && CXX=clang++-4.0"
+      - MATRIX_EVAL="CC=clang && CXX=clang++"
       - BUILD_NAME=cthread-linux-clang-py
       - WITH_DISTRIBUTED=serial WITH_PYTHON=true PY=3
     compiler: clang-4.0
@@ -118,36 +108,21 @@ matrix:
 ## test clang4 - mpi with threading backend ##
   - name: "linux, clang, mpi, py"
     os: linux
-    dist: trusty
-    python: 3.6
+    dist: bionic
     addons:
       apt:
         sources:
-          - ubuntu-toolchain-r-test
-          - llvm-toolchain-trusty-4.0
         packages:
-          - clang-4.0
-          - g++-6
           - openmpi-bin
           - libopenmpi-dev
     env:
-      - MATRIX_EVAL="CC=clang-4.0 && CXX=clang++-4.0"
+      - MATRIX_EVAL="CC=clang && CXX=clang++"
       - BUILD_NAME=mpi-linux-clang-py
       - WITH_DISTRIBUTED=mpi WITH_PYTHON=true PY=3
     compiler: clang-4.0
 
 before_install:
   - if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then export HOMEBREW_NO_AUTO_UPDATE=1; brew cask uninstall --force oclint; fi
-  - |
-    if [[ "$WITH_PYTHON" == "true" ]]; then
-      if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then
-        source ~/virtualenv/python3.6/bin/activate
-      elif [[ "$TRAVIS_OS_NAME" == "osx" ]]; then
-        brew unlink python
-        brew install https://raw.githubusercontent.com/Homebrew/homebrew-core/e128fa1bce3377de32cbf11bd8e46f7334dfd7a6/Formula/python.rb
-        brew switch python 3.6.5
-      fi
-    fi
 
 install:
   - |
diff --git a/.ycm_extra_conf.py b/.ycm_extra_conf.py
index 8fb825ba3b1abe8630ae830f74807e7897bfda8e..a61e461ac159eaeefa9debeb1790b91c436434ac 100644
--- a/.ycm_extra_conf.py
+++ b/.ycm_extra_conf.py
@@ -36,19 +36,12 @@ import ycm_core
 # CHANGE THIS LIST OF FLAGS. YES, THIS IS THE DROID YOU HAVE BEEN LOOKING FOR.
 flags = [
     '-DNDEBUG',
-    '-DARB_HAVE_CTHREAD',
     '-std=c++14',
     '-x',
     'c++',
     '-I',
-    'src',
-    '-I',
-    '.',
-    '-I',
     'modcc',
     '-I',
-    'include',
-    '-I',
     'arbor',
     '-I',
     'arbor/include',
@@ -57,17 +50,15 @@ flags = [
     '-I',
     'ext/json/single_include',
     '-I',
-    'build/include',
-    '-I',
-    '/cm/shared/apps/cuda/8.0.44/include', # TODO: run a command to find this on "any" system
-    '-I',
     'python/pybind11/include',
     '-I',
-    '/usr/include/python3.6m', # TODO: run a command to find this on "any" system
-    '-I',
     'sup/include',
     '-I',
-    '/usr/include/python3.7m',
+    'test',
+    '-I',
+    '/usr/include/python3.8',
+    '-I',
+    'python',
 ]
 
 # Set this to the absolute path to the folder (NOT the file!) containing the
@@ -153,13 +144,6 @@ def FlagsForFile( filename, **kwargs ):
       compilation_info.compiler_flags_,
       compilation_info.compiler_working_dir_ )
 
-    # NOTE: This is just for YouCompleteMe; it's highly likely that your project
-    # does NOT need to remove the stdlib flag. DO NOT USE THIS IN YOUR
-    # ycm_extra_conf IF YOU'RE NOT 100% SURE YOU NEED IT.
-    try:
-      final_flags.remove( '-stdlib=libc++' )
-    except ValueError:
-      pass
   else:
     relative_to = DirectoryOfThisScript()
     final_flags = MakeRelativePathsInFlagsAbsolute( flags, relative_to )
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 29a2c9066886554fc561f0a645f15459fbea99d9..fc86e4465e89791c936c4cead21aed11ceaed038 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1,5 +1,4 @@
-# 3.9 requirement for CUDA language support and MPI support.
-cmake_minimum_required(VERSION 3.9)
+cmake_minimum_required(VERSION 3.12)
 
 file(READ VERSION FULL_VERSION_STRING)
 string(STRIP "${FULL_VERSION_STRING}" FULL_VERSION_STRING)
diff --git a/cmake/CompilerOptions.cmake b/cmake/CompilerOptions.cmake
index dac4325ea64f169877d72b27e2e09b5f4e02539e..8ff6415acc9bbc11b0d6fe6f1afc2e10ccf4a116 100644
--- a/cmake/CompilerOptions.cmake
+++ b/cmake/CompilerOptions.cmake
@@ -96,55 +96,6 @@ function(set_arch_target optvar arch)
         else()
             set(arch_opt "-mcpu=${arch}")
         endif()
-
-    elseif(CMAKE_CXX_COMPILER_ID MATCHES "Intel")
-        # Translate target architecture names to Intel-compatible names.
-        # icc 17 recognizes the following specific microarchitecture names for -mtune:
-        #     broadwell, haswell, ivybridge, knl, sandybridge, skylake
-
-        if(arch MATCHES "sandybridge")
-            set(tune "${arch}")
-            set(arch "AVX")
-        elseif(arch MATCHES "ivybridge")
-            set(tune "${arch}")
-            set(arch "CORE-AVX-I")
-        elseif(arch MATCHES "broadwell|haswell|skylake")
-            set(tune "${arch}")
-            set(arch "CORE-AVX2")
-        elseif(arch MATCHES "knl")
-            set(tune "${arch}")
-            set(arch "MIC-AVX512")
-        elseif(arch MATCHES "nehalem|westmere")
-            set(tune "corei7")
-            set(arch "SSE4.2")
-        elseif(arch MATCHES "core2")
-            set(tune "core2")
-            set(arch "SSSE3")
-        elseif(arch MATCHES "native")
-            unset(tune)
-            set(arch "Host")
-        else()
-            set(tune "generic")
-            set(arch "SSE2") # default for icc
-        endif()
-
-        if(tune)
-            set(arch_opt "-x${arch};-mtune=${tune}")
-        else()
-            set(arch_opt "-x${arch}")
-        endif()
-
-    elseif(CMAKE_CXX_COMPILER_ID MATCHES "XL")
-        # xlC 13 for Linux uses -mcpu. Not even attempting to get xlC 12 for BG/Q right
-        # at this point: use CXXFLAGS as required!
-        #
-        # xlC, gcc, and clang all recognize power8 and power9 as architecture keywords.
-
-        if(arch MATCHES "native")
-            set(arch_opt "-qarch=auto")
-        else()
-            set(arch_opt "-mcpu=${arch}")
-        endif()
     endif()
 
     get_property(enabled_languages GLOBAL PROPERTY ENABLED_LANGUAGES)
diff --git a/doc/index.rst b/doc/index.rst
index b87dc5b1aa19b83fb6e97ee72d0396125c88de82..6f4e7339f16aeb443cf7071f9fa3fe8003d05b05 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -72,6 +72,8 @@ Alternative citation formats for the paper can be `downloaded here <https://ieee
    :caption: Getting Stared:
 
    install
+   python
+   single_cell
 
 .. toctree::
    :caption: Arbor Models:
diff --git a/doc/python.rst b/doc/python.rst
new file mode 100644
index 0000000000000000000000000000000000000000..d3238b346766ac0f09635615a182cb9cfbbf9d8e
--- /dev/null
+++ b/doc/python.rst
@@ -0,0 +1,48 @@
+.. _getstarted_python:
+
+Python
+======
+
+Arbor provides access to all of the C++ library's functionality in Python,
+which is the only interface for many users.
+The getting started guides will introduce Arbor via the Python interface.
+
+Before starting Arbor needs to be installed with the Python interface enabled,
+as per the `installation guide <_installarbor>`_.
+To test that Arbor is available, open `Python 3 <python2_>`_, and try the following:
+
+.. container:: example-code
+
+    .. code-block:: python
+
+        import arbor
+        arbor.config()
+
+        {'mpi': True, 'mpi4py': True, 'gpu': False, 'version': '0.3'}
+
+Calling ``arbor.config()`` returns a dictionary with information about the Arbor installation.
+This can be used to check that Arbor supports features that you require to run your model,
+or even to dynamically decide how to run a model.
+Single cell models like the one introduced here we do not require parallelism like
+that provided by MPI or GPUs, so the ``'mpi'`` and ``'gpu'`` fields can be ``False``.
+
+Performance
+--------------
+
+The Python interface can incur significant performance overheads relative to C++
+during the *model building* phase, however simulation performance will be the same
+for both interfaces.
+
+.. _python2:
+
+Python 2
+----------
+
+Python 2.7 will reach `end of life <https://pythonclock.org/>`_ in January 2020.
+Arbor should be installed using Python 3, and all examples in the documentation are in
+Python 3. It might be possible to install and run Arbor using Python 2.7, however Arbor support
+will only be provided for Python 3.
+
+NEURON users that use Python 2 can take the opportunity of trying Arbor to make
+the move to Python 3.
+
diff --git a/doc/single_cell.rst b/doc/single_cell.rst
new file mode 100644
index 0000000000000000000000000000000000000000..94de6c7306a6eb8d8d33335ba1a3ad263861fd64
--- /dev/null
+++ b/doc/single_cell.rst
@@ -0,0 +1,90 @@
+.. _single_cell:
+
+Single Cell Models
+==================
+
+Arbor breaks down building *single cell models* into the following steps:
+
+1. Defining the `morphology <single_morpho_>`_ of the cell.
+2. Labeling regions and locations on the morphology.
+3. Defining the mechanisms that will be applied to the cell.
+4. Adding ion channels and synapses (mechanisms) to labeled regions and locations.
+5. Attaching stimuli, spike detectors, event generators and probes to locations (inputs & outputs).
+
+This guide will provide a simple example workflow of this kind that the reader
+can follow along in Python, with links to separate documentation that covers
+relevant topics in more detail.
+
+.. note::
+    Most readers will be familiar with NEURON. Boxes like this
+    will be used to highlight differences between NEURON and Arbor
+    throughout the guide.
+
+    NEURON users will recognise that Arbor uses many similar concepts, and
+    an effort has been made to use the same nomenclature wherever possible.
+
+    Arbor takes a more structured approach to model building,
+    from morphology descriptions up to network connectivity, to allow model
+    descriptions that are more scalable and portable.
+
+.. _single_morpho:
+
+Morphology
+----------
+
+The first step in building a cell model is to define the cell's *morphology*.
+Conceptually, Arbor treats morphologies as a tree of truncated frustums, with
+an optional spherical segment at the root of the tree.
+These are represented as a tree of sample points, where each sample has a 3D location,
+a radius, and a tag, and a parent sample.
+
+.. note::
+    NEURON represents morphologies as a tree of cylindrical *segments*, whereas
+    in Arbor the radius can vary linearly between two sample locations.
+
+    A cylinder with equal diameter and length is used to model spherical somata
+    in NEURON, which coincidently has the same surface area as a sphere of the same diameter.
+    Arbor allows the user to optionally use a spherical section at the root
+    of the tree to represent spherical somata.
+
+.. note::
+    In NEURON cell morphologies are constructed by creating individual sections,
+    then connecting them together. In Arbor we start with an "empty"
+    sample tree, to which samples are appended to build a connected morphology.
+
+Let's start with a simple "ball and stick" model cell.
+
+.. container:: example-code
+
+    .. code-block:: python
+
+        import arbor
+        builder = arbor.flat_cell_builder()
+
+        # Start with a spherical segment with radius 10 μm.
+        # Label this segment 'soma'.
+        p = builder.add_sphere(radius=10, name='soma')
+
+        # Attach a cable to the soma with length 100 μm and constant radius 4 μm.
+        q = builder.add_cable(parent=p, length=100, radius=4, name='dend')
+
+        # Attach two dendrites to the first of length 50 μm, that taper from 4 μm to 2 μm.
+        p = builder.add_cable(parent=q, length=50, radius=(4,2), name='dend')
+        p = builder.add_cable(parent=q, length=50, radius=(4,2), name='dend')
+
+
+Building the morphology there are two approaches: construct it manually using
+``sample_tree`` or ``flat_cell_builder``, or load from swc file.
+
+TODO: cover all methods here?
+    - we could just ``flat_cell_builder`` because it is most comfortable for
+      users coming over from NEURON.
+    - have links to another page that goes into detail on all the different
+      methods for morphology building. That page could take a moderately
+      complicated, well-defined, morphology, and illustrate how to build
+      it using all of the different methods.
+
+Labeling Regions and Locations
+------------------------------
+
+
diff --git a/example/ring/ring.cpp b/example/ring/ring.cpp
index 611ca77c9d5f54034a4ee697f017263896eaef5c..91fc043baf48c1d46af4bf14c6a702ade317d7f5 100644
--- a/example/ring/ring.cpp
+++ b/example/ring/ring.cpp
@@ -226,7 +226,7 @@ int main(int argc, char** argv) {
         // The id of the only probe on the cell: the cell_member type points to (cell 0, probe 0)
         auto probe_id = cell_member_type{0, 0};
         // The schedule for sampling is 10 samples every 1 ms.
-        auto sched = arb::regular_schedule(0.1);
+        auto sched = arb::regular_schedule(1);
         // This is where the voltage samples will be stored as (time, value) pairs
         arb::trace_data<double> voltage;
         // Now attach the sampler at probe_id, with sampling schedule sched, writing to voltage
diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt
index ad34fce7044a8855f9b11a882fdac6fb52623b0b..bf4d5f006fcda97c60658f4cccac595b5eb0180f 100644
--- a/python/CMakeLists.txt
+++ b/python/CMakeLists.txt
@@ -14,16 +14,17 @@ endif()
 set(PYBIND11_CPP_STANDARD -std=c++14)
 add_subdirectory(pybind11)
 
-# The Python library. MODULE will make a Python-exclusive model.
-add_library(pyarb MODULE
+set(pyarb_source
     cells.cpp
     config.cpp
     context.cpp
     domain_decomposition.cpp
     error.cpp
     event_generator.cpp
+    flat_cell_builder.cpp
     identifiers.cpp
     morphology.cpp
+    morph_parse.cpp
     mpi.cpp
     profiler.cpp
     pyarb.cpp
@@ -31,10 +32,19 @@ add_library(pyarb MODULE
     sampling.cpp
     schedule.cpp
     simulation.cpp
+    single_cell_model.cpp
     spikes.cpp
+    s_expr.cpp
 )
 
-target_link_libraries(pyarb PRIVATE arbor pybind11::module)
+# compile the pyarb sources into an object library that will be
+# use by both the Python wrapper target (pyarb) and for the
+# unit tests of the C++ components in the Python wrapper.
+add_library(pyarb_obj OBJECT ${pyarb_source})
+target_link_libraries(pyarb_obj PRIVATE arbor pybind11::module)
+
+# The Python library. MODULE will make a Python-exclusive model.
+add_library(pyarb MODULE $<TARGET_OBJECTS:pyarb_obj>)
 
 # The output name of the pyarb .so file is "arbor", to facilitate "import arbor"
 set_target_properties(pyarb PROPERTIES OUTPUT_NAME arbor)
@@ -42,6 +52,10 @@ set_target_properties(pyarb PROPERTIES OUTPUT_NAME arbor)
 #   arbor.cpython-37m-x86_64-linux-gnu.so
 set_target_properties(pyarb PROPERTIES PREFIX "${PYTHON_MODULE_PREFIX}" SUFFIX "${PYTHON_MODULE_EXTENSION}")
 
+# this dependency has to be spelt out again, despite being added to
+# pyarb_obj because CMake.
+target_link_libraries(pyarb PRIVATE arbor pybind11::module)
+
 # Add support for mpi4py if available.
 if (ARB_WITH_MPI)
     find_python_module(mpi4py)
@@ -55,3 +69,6 @@ endif()
 find_package(PythonInterp REQUIRED)
 set(ARB_PYEXECDIR "${CMAKE_INSTALL_LIBDIR}/python${PYTHON_VERSION_MAJOR}.${PYTHON_VERSION_MINOR}/site-packages")
 install(TARGETS pyarb LIBRARY DESTINATION ${ARB_PYEXECDIR})
+
+# for unit tests on C++ side of Python wrappers
+add_subdirectory(test)
diff --git a/python/cells.cpp b/python/cells.cpp
index 6eaa3cb09f5caf1133c56913b0ff75998360c1f4..f9807142e457c19e57b862cf82fdd381514f4f8a 100644
--- a/python/cells.cpp
+++ b/python/cells.cpp
@@ -4,11 +4,17 @@
 #include <arbor/benchmark_cell.hpp>
 #include <arbor/cable_cell.hpp>
 #include <arbor/lif_cell.hpp>
+#include <arbor/morph/label_dict.hpp>
+#include <arbor/morph/locset.hpp>
+#include <arbor/morph/region.hpp>
 #include <arbor/schedule.hpp>
 #include <arbor/spike_source_cell.hpp>
+#include <arbor/util/any.hpp>
 #include <arbor/util/unique_any.hpp>
 
+#include "conversion.hpp"
 #include "error.hpp"
+#include "morph_parse.hpp"
 #include "schedule.hpp"
 #include "strprintf.hpp"
 
@@ -44,119 +50,107 @@ arb::util::unique_any convert_cell(pybind11::object o) {
 }
 
 //
-// Somewhat hacky bit of code for generating cells with random morphologies.
+//  proxies
 //
 
-// Parameters used to generate the random cell morphologies.
-struct cell_parameters {
-    cell_parameters() = default;
-
-    //  Maximum number of levels in the cell (not including the soma)
-    unsigned max_depth = 5;
-
-    // The following parameters are described as ranges.
-    // The first value is at the soma, and the last value is used on the last level.
-    // Values at levels in between are found by linear interpolation.
-    std::array<double,2> branch_probs = {1.0, 0.5}; //  Probability of a branch occuring.
-    std::array<unsigned,2> compartments = {20, 2};  //  Compartment count on a branch.
-    std::array<double,2> lengths = {200, 20};       //  Length of branch in μm.
-
-    // The number of synapses per cell.
-    unsigned synapses = 1;
-
-    friend std::ostream& operator<<(std::ostream& o, const cell_parameters& p) {
-        return
-            o << "<cell_parameters: depth " << p.max_depth
-              << ", synapses " << p.synapses
-              << ", branch_probs [" << p.branch_probs[0] << ":" << p.branch_probs[1] << "]"
-              << ", compartments [" << p.compartments[0] << ":" << p.compartments[1] << "]"
-              << ", lengths ["      << p.lengths[0]      << ":" << p.lengths[1] << "]>";
-    }
-};
+struct label_dict_proxy {
+    using str_map = std::unordered_map<std::string, std::string>;
+    arb::label_dict dict;
+    str_map cache;
+    std::vector<std::string> locsets;
+    std::vector<std::string> regions;
 
-// Helper used to interpolate in branch_cell.
-template <typename T>
-double interp(const std::array<T,2>& r, unsigned i, unsigned n) {
-    double p = i * 1./(n-1);
-    double r0 = r[0];
-    double r1 = r[1];
-    return r[0] + p*(r1-r0);
-}
+    label_dict_proxy() = default;
 
-arb::cable_cell make_cable_cell(arb::cell_gid_type gid, const cell_parameters& params) {
-    arb::sample_tree tree;
-
-    // Add soma.
-    double soma_radius = 12.6157/2.0;
-    tree.append(arb::mnpos, {{0,0,0,soma_radius}, 1}); // For area of 500 μm².
-
-    std::vector<std::vector<unsigned>> levels;
-    levels.push_back({0});
-
-    // Standard mersenne_twister_engine seeded with gid.
-    std::mt19937 gen(gid);
-    std::uniform_real_distribution<double> dis(0, 1);
-
-    double dend_radius = 0.5; // Diameter of 1 μm for each cable.
-
-    double dist_from_soma = soma_radius;
-    for (unsigned i=0; i<params.max_depth; ++i) {
-        // Branch prob at this level.
-        double bp = interp(params.branch_probs, i, params.max_depth);
-        // Length at this level.
-        double l = interp(params.lengths, i, params.max_depth);
-        // Number of compartments at this level.
-        unsigned nc = std::round(interp(params.compartments, i, params.max_depth));
-
-        std::vector<unsigned> sec_ids;
-        for (unsigned sec: levels[i]) {
-            for (unsigned j=0; j<2; ++j) {
-                if (dis(gen)<bp) {
-                    auto z = dist_from_soma;
-                    auto p = tree.append(sec, {{0,0,z,dend_radius}, 3});
-                    if (nc>1) {
-                        auto dz = l/nc;
-                        for (unsigned k=1; k<nc; ++k) {
-                            p = tree.append(p, {{0,0,z+k*dz, dend_radius}, 3});
-                        }
-                    }
-                    sec_ids.push_back(tree.append(p, {{0,0,z+l,dend_radius}, 3}));
-                }
-            }
+    label_dict_proxy(const str_map& in) {
+        for (auto& i: in) {
+            set(i.first.c_str(), i.second.c_str());
         }
-        if (sec_ids.empty()) {
-            break;
-        }
-        levels.push_back(sec_ids);
-
-        dist_from_soma += l;
     }
 
-    arb::label_dict d;
-
-    using arb::reg::tagged;
-    d.set("soma",      tagged(1));
-    d.set("dendrites", join(tagged(3), tagged(4)));
-
-    arb::cable_cell cell(arb::morphology(tree, true), d);
-
-    cell.paint("soma", "hh");
-    cell.paint("dendrites", "pas");
-    cell.default_parameters.axial_resistivity = 100; // [Ω·cm]
-
-    // Add spike threshold detector at the soma.
-    cell.place(arb::mlocation{0,0}, arb::threshold_detector{10});
+    std::size_t size() const  {
+        return locsets.size() + regions.size();
+    }
 
-    // Add a synapse to the mid point of the first dendrite.
-    cell.place(arb::mlocation{1, 0.5}, "expsyn");
+    void set(const char* name, const char* desc) {
+        using namespace std::string_literals;
+        // The following code takes an input name and a region or locset
+        // description, e.g.:
+        //      name='reg', desc='(tag 4)'
+        //      name='loc', desc='(terminal)'
+        //      name='foo', desc='(join (tag 2) (tag 3))'
+        // Then it parses the description, and tests whether the description
+        // is a region or locset, and updates the label dictionary appropriately.
+        // Errors occur when:
+        //  * the name is not a valid name.
+        //  * a region is described with a name that matches an existing locset
+        //    (and vice versa.)
+        //  * the description is not well formed, e.g. it contains a syntax error.
+        //  * the description is well-formed, but describes neither a region or locset.
+        try{
+            // Test that the identifier is valid, i.e.
+            //  * only numbers, letters and underscore.
+            //  * no leading number or underscore.
+            if (!test_identifier(name)) {
+                throw std::string(util::pprintf("'{}' is not a valid label name.", name));
+            }
+            // Parse the input string into an s-expression.
+            auto parsed = parse(desc);
+            // Evaluate the s-expression to build a region/locset.
+            auto result = eval(parsed);
+            if (!result) { // an error parsing / evaluating description.
+                throw std::string(result.error().message);
+            }
+            else if (result->type()==typeid(arb::region)) { // describes a region.
+                dict.set(name, std::move(arb::util::any_cast<arb::region&>(*result)));
+                auto it = std::lower_bound(regions.begin(), regions.end(), name);
+                if (it==regions.end() || *it!=name) regions.insert(it, name);
+            }
+            else if (result->type()==typeid(arb::locset)) { // describes a locset.
+                dict.set(name, std::move(arb::util::any_cast<arb::locset&>(*result)));
+                auto it = std::lower_bound(locsets.begin(), locsets.end(), name);
+                if (it==locsets.end() || *it!=name) locsets.insert(it, name);
+            }
+            else {
+                // Successfully parsed an expression that is neither region nor locset.
+                throw util::pprintf("The defninition of '{} = {}' does not define a valid region or locset.", name, desc);
+            }
+            // The entry was added succesfully: store it in the cache.
+            cache[name] = desc;
+        }
+        catch (std::string msg) {
+            const char* base = "\nError adding the label '{}' = '{}'\n{}\n";
 
-    // Add additional synapses that will not be connected to anything.
-    for (unsigned i=1u; i<params.synapses; ++i) {
-        cell.place(arb::mlocation{1, 0.5}, "expsyn");
+            throw std::runtime_error(util::pprintf(base, name, desc, msg));
+        }
+        // Exceptions are thrown in parse or eval if an unexpected error occured.
+        catch (std::exception& e) {
+            const char* msg =
+                "\n----- internal error -------------------------------------------"
+                "\nError parsing the label: '{}' = '{}'"
+                "\n"
+                "\n{}"
+                "\n"
+                "\nPlease file a bug report with this full error message at:"
+                "\n    github.com/arbor-sim/arbor/issues"
+                "\n----------------------------------------------------------------";
+            throw arb::arbor_internal_error(util::pprintf(msg, name, desc, e.what()));
+        }
     }
 
-    return cell;
-}
+    std::string to_string() const {
+        std::string s;
+        s += "(label_dict";
+        for (auto& x: dict.regions()) {
+            s += util::pprintf(" (region  '{}' {})", x.first, x.second);
+        }
+        for (auto& x: dict.locsets()) {
+            s += util::pprintf(" (locset '{}' {})", x.first, x.second);
+        }
+        s += ")";
+        return s;
+    }
+};
 
 //
 // string printers
@@ -164,12 +158,21 @@ arb::cable_cell make_cable_cell(arb::cell_gid_type gid, const cell_parameters& p
 
 std::string lif_str(const arb::lif_cell& c){
     return util::pprintf(
-                "<arbor.lif_cell: tau_m {}, V_th {}, C_m {}, E_L {}, V_m {}, t_ref {}, V_reset {}>",
-                c.tau_m, c.V_th, c.C_m, c.E_L, c.V_m, c.t_ref, c.V_reset);
+        "<arbor.lif_cell: tau_m {}, V_th {}, C_m {}, E_L {}, V_m {}, t_ref {}, V_reset {}>",
+        c.tau_m, c.V_th, c.C_m, c.E_L, c.V_m, c.t_ref, c.V_reset);
+}
+
+
+std::string mechanism_desc_str(const arb::mechanism_desc& md) {
+    return util::pprintf("<arbor.mechanism: name '{}', parameters {}",
+            md.name(), util::dictionary_csv(md.values()));
 }
 
 void register_cells(pybind11::module& m) {
     using namespace pybind11::literals;
+    using arb::util::optional;
+
+    // arb::spike_source_cell
 
     pybind11::class_<arb::spike_source_cell> spike_source_cell(m, "spike_source_cell",
         "A spike source cell, that generates a user-defined sequence of spikes that act as inputs for other cells in the network.");
@@ -190,6 +193,8 @@ void register_cells(pybind11::module& m) {
         .def("__repr__", [](const arb::spike_source_cell&){return "<arbor.spike_source_cell>";})
         .def("__str__",  [](const arb::spike_source_cell&){return "<arbor.spike_source_cell>";});
 
+    // arb::benchmark_cell
+
     pybind11::class_<arb::benchmark_cell> benchmark_cell(m, "benchmark_cell",
         "A benchmarking cell, used by Arbor developers to test communication performance.\n"
         "A benchmark cell generates spikes at a user-defined sequence of time points, and\n"
@@ -216,47 +221,325 @@ void register_cells(pybind11::module& m) {
         .def("__repr__", [](const arb::benchmark_cell&){return "<arbor.benchmark_cell>";})
         .def("__str__",  [](const arb::benchmark_cell&){return "<arbor.benchmark_cell>";});
 
+    // arb::lif_cell
+
     pybind11::class_<arb::lif_cell> lif_cell(m, "lif_cell",
         "A benchmarking cell, used by Arbor developers to test communication performance.");
 
     lif_cell
         .def(pybind11::init<>())
-        .def_readwrite("tau_m",     &arb::lif_cell::tau_m,      "Membrane potential decaying constant [ms].")
-        .def_readwrite("V_th",      &arb::lif_cell::V_th,       "Firing threshold [mV].")
-        .def_readwrite("C_m",       &arb::lif_cell::C_m,        "Membrane capacitance [pF].")
-        .def_readwrite("E_L",       &arb::lif_cell::E_L,        "Resting potential [mV].")
-        .def_readwrite("V_m",       &arb::lif_cell::V_m,        "Initial value of the Membrane potential [mV].")
-        .def_readwrite("t_ref",     &arb::lif_cell::t_ref,      "Refractory period [ms].")
-        .def_readwrite("V_reset",   &arb::lif_cell::V_reset,    "Reset potential [mV].")
+        .def_readwrite("tau_m", &arb::lif_cell::tau_m,
+            "Membrane potential decaying constant [ms].")
+        .def_readwrite("V_th", &arb::lif_cell::V_th,
+            "Firing threshold [mV].")
+        .def_readwrite("C_m", &arb::lif_cell::C_m,
+            " Membrane capacitance [pF].")
+        .def_readwrite("E_L", &arb::lif_cell::E_L,
+            "Resting potential [mV].")
+        .def_readwrite("V_m", &arb::lif_cell::V_m,
+            "Initial value of the Membrane potential [mV].")
+        .def_readwrite("t_ref", &arb::lif_cell::t_ref,
+            "Refractory period [ms].")
+        .def_readwrite("V_reset", &arb::lif_cell::V_reset,
+            "Reset potential [mV].")
         .def("__repr__", &lif_str)
         .def("__str__",  &lif_str);
 
-    pybind11::class_<cell_parameters> cell_params(m, "cell_parameters", "Parameters used to generate the random cell morphologies.");
-    cell_params
+    // arb::label_dict
+
+    pybind11::class_<label_dict_proxy> label_dict(m, "label_dict",
+        "A dictionary of labelled region and locset definitions, with a\n"
+        "unique label is assigned to each definition.");
+    label_dict
+        .def(pybind11::init<>(), "Create an empty label dictionary.")
+        .def(pybind11::init<const std::unordered_map<std::string, std::string>&>(),
+            "Initialize a label dictionary from a dictionary with string labels as keys,"
+            " and corresponding definitions as strings.")
+        .def("__setitem__",
+            [](label_dict_proxy& l, const char* name, const char* desc) {
+                l.set(name, desc);})
+        .def("__getitem__",
+            [](label_dict_proxy& l, const char* name) {
+                if (!l.cache.count(name)) {
+                    throw std::runtime_error(util::pprintf("\nKeyError: '{}'", name));
+                }
+                return l.cache.at(name);
+            })
+        .def("__len__", &label_dict_proxy::size)
+        .def("__iter__",
+            [](const label_dict_proxy &ld) {
+                return pybind11::make_key_iterator(ld.cache.begin(), ld.cache.end());},
+            pybind11::keep_alive<0, 1>())
+        .def_readonly("regions", &label_dict_proxy::regions,
+             "The region definitions.")
+        .def_readonly("locsets", &label_dict_proxy::locsets,
+             "The locset definitions.")
+        .def("__repr__", [](const label_dict_proxy& d){return d.to_string();})
+        .def("__str__",  [](const label_dict_proxy& d){return d.to_string();});
+
+    // Data structures used to describe mechanisms, electrical properties,
+    // gap junction properties, etc.
+
+    // arb::cable_cell_ion_data
+
+    pybind11::class_<arb::initial_ion_data> ion_data(m, "ion",
+        "For setting ion properties (internal and external concentration and reversal potential) on cells and regions.");
+    ion_data
+        .def(pybind11::init(
+                [](const char* name,
+                   optional<double> int_con, optional<double> ext_con,
+                   optional<double> rev_pot)
+                {
+                    arb::initial_ion_data x;
+                    x.ion = name;
+                    if (int_con) x.initial.init_int_concentration = *int_con;
+                    if (ext_con) x.initial.init_int_concentration = *ext_con;
+                    if (rev_pot) x.initial.init_reversal_potential = *rev_pot;
+                    return x;
+                }
+            ),
+            "ion_name"_a,
+            pybind11::arg_v("int_con", pybind11::none(), "Intial internal concentration [mM]"),
+            pybind11::arg_v("ext_con", pybind11::none(), "Intial external concentration [mM]"),
+            pybind11::arg_v("rev_pot", pybind11::none(), "Intial reversal potential [mV]"),
+            "If concentrations or reversal potential are specified as 'None',"
+            " cell default or global default value will be used, in that order if set.");
+
+    // arb::mechanism_desc
+
+    pybind11::class_<arb::mechanism_desc> mechanism_desc(m, "mechanism");
+    mechanism_desc
+        .def(pybind11::init([](const char* n) {return arb::mechanism_desc{n};}))
+        // allow construction of a description with parameters provided in a dictionary:
+        //      mech = arbor.mechanism('mech_name', {'param1': 1.2, 'param2': 3.14})
+        .def(pybind11::init(
+            [](const char* name, std::unordered_map<std::string, double> params) {
+                arb::mechanism_desc md(name);
+                for (const auto& p: params) md.set(p.first, p.second);
+                return md;
+            }),
+            "name"_a, "The name of the mechanism",
+            "params"_a, "A dictionary of parameter values, with parameter name as key.",
+            "Example usage setting pararmeters:\n"
+            "  m = arbor.mechanism('expsyn', {'tau': 1.4})\n"
+            "will create parameters for the 'expsyn' mechanism, with the provided value\n"
+            "for 'tau' overrides the default. If a parameter is not set, the default\n"
+            "(as defined in NMODL) is used.\n\n"
+            "Example overriding a global parameter:\n"
+            "  m = arbor.mechanism('nernst/R=8.3145,F=96485')")
+        .def("set",
+            [](arb::mechanism_desc& md, std::string name, double value) {
+                md.set(name, value);
+            },
+            "name"_a, "value"_a, "Set parameter value.")
+        .def_property_readonly("name",
+            [](const arb::mechanism_desc& md) {
+                return md.name();
+            },
+            "The name of the mechanism.")
+        .def_property_readonly("values",
+            [](const arb::mechanism_desc& md) {
+                return md.values();
+            }, "A dictionary of parameter values with parameter name as key.")
+        .def("__repr__",
+                [](const arb::mechanism_desc& md) {
+                    return util::pprintf("<arbor.mechanism: name '{}', parameters {}", md.name(), util::dictionary_csv(md.values())); })
+        .def("__str__",
+                [](const arb::mechanism_desc& md) {
+                    return util::pprintf("('{}' {})", md.name(), util::dictionary_csv(md.values())); });
+
+    // arb::gap_junction_site
+
+    pybind11::class_<arb::gap_junction_site> gjsite(m, "gap_junction",
+            "For marking a location on a cell morphology as a gap junction site.");
+    gjsite
         .def(pybind11::init<>())
-        .def_readwrite("depth",        &cell_parameters::max_depth,     "The maximum depth of the branch structure.")
-        .def_readwrite("lengths",      &cell_parameters::lengths,       "Length of branch [μm], given as range.")
-        .def_readwrite("synapses",     &cell_parameters::synapses,      "The number of randomly generated synapses on the cell.")
-        .def_readwrite("branch_probs", &cell_parameters::branch_probs,  "Probability of a branch occuring, given as range.")
-        .def_readwrite("compartments", &cell_parameters::compartments,  "Compartment count on a branch, given as range.")
-        .def("__repr__", util::to_string<cell_parameters>)
-        .def("__str__",  util::to_string<cell_parameters>);
-
-    // Wrap cable cell description type.
-    // Cable cells are going to be replaced with a saner API, so we don't go
-    // adding much in the way of interface. Instead we just provide a helper
-    // that will generate random cell morphologies for benchmarking.
-    pybind11::class_<arb::cable_cell> cable_cell(m, "cable_cell");
-
+        .def("__repr__", [](const arb::gap_junction_site&){return "<arbor.gap_junction>";})
+        .def("__str__", [](const arb::gap_junction_site&){return "<arbor.gap_junction>";});
+
+    // arb::i_clamp
+
+    pybind11::class_<arb::i_clamp> i_clamp(m, "iclamp",
+        "A current clamp, for injecting a single pulse of current with fixed duration and current.");
+    i_clamp
+        .def(pybind11::init(
+                [](double ts, double dur, double cur) {
+                    return arb::i_clamp{ts, dur, cur};
+                }), "tstart"_a=0, "duration"_a=0, "current"_a=0)
+        .def_readonly("tstart", &arb::i_clamp::delay,       "Time at which current starts [ms]")
+        .def_readonly("duration", &arb::i_clamp::duration,  "Duration of the current injection [ms]")
+        .def_readonly("current", &arb::i_clamp::amplitude,  "Amplitude of the injected current [nA]")
+        .def("__repr__", [](const arb::i_clamp& c){
+            return util::pprintf("(iclamp {} {} {})", c.delay, c.duration, c.amplitude);})
+        .def("__str__", [](const arb::i_clamp& c){
+            return util::pprintf("<arbor.iclamp: tstart {} ms, duration {} ms, current {} nA>",
+                                 c.delay, c.duration, c.amplitude);});
+
+    // arb::threshold_detector
+
+    pybind11::class_<arb::threshold_detector> detector(m, "spike_detector",
+            "A spike detector, generates a spike when voltage crosses a threshold.");
+    detector
+        .def(pybind11::init(
+            [](double thresh) {
+                return arb::threshold_detector{thresh};
+            }), "threshold"_a)
+        .def_readonly("threshold", &arb::threshold_detector::threshold, "Voltage threshold of spike detector [ms]")
+        .def("__repr__", [](const arb::threshold_detector& d){
+            return util::pprintf("<arbor.threshold_detector: threshold {} mV>", d.threshold);})
+        .def("__str__", [](const arb::threshold_detector& d){
+            return util::pprintf("(threshold_detector {})", d.threshold);});
+
+    // arb::cable_cell
+
+    pybind11::class_<arb::cable_cell> cable_cell(m, "cable_cell",
+        "Represents morphologically-detailed cell models, with morphology represented as a\n"
+        "tree of one-dimensional cable segments.");
     cable_cell
+        .def(pybind11::init(
+            [](const arb::morphology& m, const label_dict_proxy& labels) {
+                return arb::cable_cell(m, labels.dict);
+            }), "morphology"_a, "labels"_a)
+        .def(pybind11::init(
+            [](const arb::sample_tree& t, const label_dict_proxy& labels) {
+                return arb::cable_cell(arb::morphology(t), labels.dict);
+            }),
+            "morphology"_a, "labels"_a,
+            "Construct with a morphology derived from a sample_tree, with automatic detection of whether\n"
+            "the morphology has a spherical root/soma.")
+        .def_property_readonly("num_branches",
+            [](const arb::cable_cell& c) {return c.morphology().num_branches();},
+            "The number of unbranched cable sections in the morphology.")
+        // Set cell-wide properties
+        .def("set_properties",
+            [](arb::cable_cell& c,
+               optional<double> Vm, optional<double> cm,
+               optional<double> rL, optional<double> tempK)
+            {
+                if (Vm) c.default_parameters.init_membrane_potential = Vm;
+                if (cm) c.default_parameters.membrane_capacitance=cm;
+                if (rL) c.default_parameters.axial_resistivity=rL;
+                if (tempK) c.default_parameters.temperature_K=tempK;
+            },
+            "Vm"_a=pybind11::none(), "cm"_a=pybind11::none(), "rL"_a=pybind11::none(), "tempK"_a=pybind11::none(),
+            "Set default values for cable and cell properties. These values can be overridden on specific regions using the paint interface.\n"
+            " Vm:    initial membrane voltage [mV].\n"
+            " cm:    membrane capacitance [F/m²].\n"
+            " rL:    axial resistivity [Ω·cm].\n"
+            " tempK: temperature [Kelvin].")
+        .def("set_ion",
+            [](arb::cable_cell& c, const char* ion,
+               optional<double> int_con, optional<double> ext_con,
+               optional<double> rev_pot, optional<arb::mechanism_desc> method)
+            {
+                auto& data = c.default_parameters.ion_data[ion];
+                if (int_con) data.init_int_concentration = *int_con;
+                if (ext_con) data.init_ext_concentration = *ext_con;
+                if (rev_pot) data.init_reversal_potential = *rev_pot;
+                if (method)  c.default_parameters.reversal_potential_method[ion] = *method;
+            },
+            "ion"_a,
+            "int_con"_a=pybind11::none(),
+            "ext_con"_a=pybind11::none(),
+            "rev_pot"_a=pybind11::none(),
+            "method"_a=pybind11::none(),
+            "Set the propoerties of ion species named 'ion' that will be applied\n"
+            "by default everywhere on the cell. Species concentrations and reversal\n"
+            "potential can be overridden on specific regions using the paint interface, \n"
+            "while the method for calculating reversal potential is global for all\n"
+            "compartments in the cell, and can't be overriden locally.\n"
+            " ion:     name of ion species.\n"
+            " int_con: initial internal concentration [mM].\n"
+            " ext_con: initial external concentration [mM].\n"
+            " rev_pot: reversal potential [mV].\n"
+            " method:  method for calculating reversal potential.")
+        // Paint mechanisms.
+        .def("paint",
+            [](arb::cable_cell& c, const char* region, const arb::mechanism_desc& d) {
+                c.paint(region, d);
+            },
+            "region"_a, "mechanism"_a,
+            "Associate a mechanism with a region.")
+        .def("paint",
+            [](arb::cable_cell& c, const char* region, const char* mech_name) {
+                c.paint(region, mech_name);
+            },
+            "region"_a, "mechanism"_a,
+            "Associate a mechanism with a region.")
+        // Paint membrane/static properties.
+        .def("paint",
+            [](arb::cable_cell& c,
+                const char* region,
+               optional<double> Vm, optional<double> cm,
+               optional<double> rL, optional<double> tempK)
+            {
+                if (Vm) c.paint(region, arb::init_membrane_potential{*Vm});
+                if (cm) c.paint(region, arb::membrane_capacitance{*cm});
+                if (rL) c.paint(region, arb::axial_resistivity{*rL});
+                if (tempK) c.paint(region, arb::temperature_K{*tempK});
+            },
+            "region"_a, "Vm"_a=pybind11::none(), "cm"_a=pybind11::none(), "rL"_a=pybind11::none(), "tempK"_a=pybind11::none(),
+            "Set cable properties on a region.\n"
+            " region: initial membrane voltage [mV].\n"
+            " cm:     membrane capacitance [F/m²].\n"
+            " rL:     axial resistivity [Ω·cm].\n"
+            " tempK:  temperature [Kelvin].")
+
+        // Paint ion species initial conditions on a region.
+        .def("paint",
+            [](arb::cable_cell& c, const char* region, const arb::initial_ion_data& d) {
+                c.paint(region, d);
+            },
+            "region"_a, "ion_data"_a,
+            "Set ion species properties conditions on a region.")
+        // Place synapses
+        .def("place",
+            [](arb::cable_cell& c, const char* locset, const arb::mechanism_desc& d) {
+                c.place(locset, d); },
+            "locations"_a, "mechanism"_a,
+            "Place one instance of synapse described by 'mechanism' to each location in 'locations'.")
+        .def("place",
+            [](arb::cable_cell& c, const char* locset, const char* mech_name) {
+                c.place(locset, mech_name);
+            },
+            "locations"_a, "mechanism"_a,
+            "Place one instance of synapse described by 'mechanism' to each location in 'locations'.")
+        // Place gap junctions.
+        .def("place",
+            [](arb::cable_cell& c, const char* locset, const arb::gap_junction_site& site) {
+                c.place(locset, site);
+            },
+            "locations"_a, "gapjunction"_a,
+            "Place one gap junction site at each location in 'locations'.")
+        // Place current clamp stimulus.
+        .def("place",
+            [](arb::cable_cell& c, const char* locset, const arb::i_clamp& stim) {
+                c.place(locset, stim);
+            },
+            "locations"_a, "iclamp"_a,
+            "Add a current stimulus at each location in locations.")
+        // Place spike detector.
+        .def("place",
+            [](arb::cable_cell& c, const char* locset, const arb::threshold_detector& d) {
+                c.place(locset, d);
+            },
+            "locations"_a, "detector"_a,
+            "Add a voltage spike detector at each location in locations.")
+        // Discretization control.
+        .def("compartments_on_samples",
+            [](arb::cable_cell& c) {c.default_parameters.discretization = arb::cv_policy_every_sample{};},
+            "Decompose each branch into compartments defined by sample locations.")
+        .def("compartments_length",
+            [](arb::cable_cell& c, double len) {
+                c.default_parameters.discretization = arb::cv_policy_max_extent{len};
+            },
+            "maxlen"_a, "Decompose each branch into compartments of equal length, not exceeding maxlen.")
+        .def("compartments_per_branch",
+            [](arb::cable_cell& c, unsigned n) {c.default_parameters.discretization = arb::cv_policy_fixed_per_branch{n};},
+            "n"_a, "Decompose each branch into n compartments of equal length.")
+        // Stringification
         .def("__repr__", [](const arb::cable_cell&){return "<arbor.cable_cell>";})
         .def("__str__",  [](const arb::cable_cell&){return "<arbor.cable_cell>";});
-
-    m.def("make_cable_cell", &make_cable_cell,
-        "Construct a branching cell with a random morphology and synapse end points locations described by params.\n"
-        "seed is an integral value used to seed the random number generator, for which the gid of the cell is a good default.",
-        "seed"_a,
-        "params"_a=cell_parameters());
 }
 
-}
+} // namespace pyarb
diff --git a/python/conversion.hpp b/python/conversion.hpp
index 6db53d8ec4287be78cdfc9112d108e5809dfb649..5d58748adcc479fc4e5529390a3593e474e7448d 100644
--- a/python/conversion.hpp
+++ b/python/conversion.hpp
@@ -17,10 +17,17 @@ namespace pybind11 { namespace detail {
 namespace pyarb {
 
 struct is_nonneg {
+    template<typename T>
+    constexpr bool operator()(const T& v) {
+        return v>=T(0);
+    }
+};
+
+struct is_positive {
     template<typename T>
     constexpr
     bool operator()(const T& v) {
-        return v>=T(0);
+        return v>T(0);
     }
 };
 
@@ -65,4 +72,19 @@ arb::util::optional<T> py2optional(pybind11::object o, const char* msg) {
     return o.is_none()? arb::util::nullopt: arb::util::optional<T>(std::move(value));
 }
 
+// Attempt to cast a Python object to a C++ type T.
+// Returns an optional that is set if o could be cast
+// to T, otherwise it is empty. Hence not being able
+// to cast is not an error.
+template <typename T>
+arb::util::optional<T> try_cast(pybind11::object o) {
+    try {
+        return o.cast<T>();
+    }
+    // Ignore cast_error: if unable to perform cast.
+    catch (pybind11::cast_error& e) {}
+
+    return arb::util::nullopt;
 }
+
+} // namespace arb
diff --git a/python/error.hpp b/python/error.hpp
index d475f2fa64eb6d81316dc80e5e576cee0e1c04e0..c97dad4331812268066aaa43676d51f0f0fdd631 100644
--- a/python/error.hpp
+++ b/python/error.hpp
@@ -4,6 +4,13 @@
 #include <stdexcept>
 #include <string>
 
+#include <pybind11/pybind11.h>
+
+#include <arbor/arbexcept.hpp>
+#include <arbor/util/either.hpp>
+
+#include "strprintf.hpp"
+
 namespace pyarb {
 
 extern std::exception_ptr py_exception;
@@ -45,4 +52,67 @@ auto try_catch_pyexception(L func, const char* msg){
         throw;
     }
 }
+
+template <typename T, typename E>
+struct hopefully {
+    using value_type = T;
+    using error_type = E;
+    arb::util::either<value_type, error_type> state;
+
+    hopefully(const hopefully&) = default;
+
+    hopefully(value_type x): state(std::move(x)) {}
+    hopefully(error_type x): state(std::move(x)) {}
+
+    const value_type& operator*() const {
+        return try_get();
+    }
+    value_type& operator*() {
+        return try_get();
+    }
+    const value_type* operator->() const {
+        return &try_get();
+    }
+    value_type* operator->() {
+        return &try_get();
+    }
+
+    operator bool() const {
+        return (bool)state;
+    }
+
+    const error_type& error() const {
+        try {
+            return state.template get<1>();
+        }
+        catch(arb::util::either_invalid_access& e) {
+            throw arb::arbor_internal_error(
+                "Attempt to get an error from a valid hopefully wrapper.");
+        }
+    }
+
+private:
+
+    const value_type& try_get() const {
+        try {
+            return state.template get<0>();
+        }
+        catch(arb::util::either_invalid_access& e) {
+            throw arbor_internal_error(util::pprintf(
+                "Attempt to unwrap a hopefully with error state '{}'",
+                error().message));
+        }
+    }
+    value_type& try_get() {
+        try {
+            return state.template get<0>();
+        }
+        catch(arb::util::either_invalid_access& e) {
+            throw arb::arbor_internal_error(util::pprintf(
+                "Attempt to unwrap a hopefully with error state '{}'",
+                error().message));
+        }
+    }
+};
+
 } // namespace pyarb
diff --git a/python/example/ring.py b/python/example/ring.py
index aec4f5cbaccd6eb405e569599f1b30a2f9dd326b..94136094a93e0acc72a124383ce20cfe75439462 100644
--- a/python/example/ring.py
+++ b/python/example/ring.py
@@ -2,14 +2,53 @@ import sys
 import arbor
 import matplotlib.pyplot as plt
 
+# Construct a cell with the following morphology.
+# The soma (at the root of the tree) is marked 's', and
+# the end of each branch i is marked 'bi'.
+#
+#         b2
+#        /
+# s----b1
+#        \
+#         b3
+
+def make_cable_cell(gid):
+    b = arbor.flat_cell_builder()
+
+    # Soma with radius 6 μm.
+    s  = b.add_sphere(6, "soma")
+    # Single dendrite of length 100 μm and radius 2 μm attached to soma.
+    b1 = b.add_cable(parent=s, length=100, radius=2, name="dend", ncomp=1)
+    # Attach two dendrites of length 50 μm to the end of the first dendrite.
+    # Radius tapers from 2 to 0.5 μm over the length of the dendrite.
+    b2 = b.add_cable(parent=b1, length=50, radius=(2,0.5), name="dend", ncomp=1)
+    # Constant radius of 1 μm over the length of the dendrite.
+    b3 = b.add_cable(parent=b1, length=50, radius=1, name="dend", ncomp=1)
+
+    # Mark location for synapse at the midpoint of branch 1 (the first dendrite).
+    b.add_label('synapse_site', '(location 1 0.5)')
+    # Mark the root of the tree.
+    b.add_label('root', '(root)')
+
+    cell = b.build()
+
+    # Put hh dynamics on soma, and passive properties on the dendrites.
+    cell.paint('soma', 'hh')
+    cell.paint('dend', 'pas')
+    # Attach a single synapse.
+    cell.place('synapse_site', 'expsyn')
+    # Attach a spike detector with threshold of -10 mV.
+    cell.place('root', arbor.spike_detector(-10))
+
+    return cell
+
 class ring_recipe (arbor.recipe):
 
-    def __init__(self, n=4):
+    def __init__(self, n=10):
         # The base C++ class constructor must be called first, to ensure that
         # all memory in the C++ class is initialized correctly.
         arbor.recipe.__init__(self)
         self.ncells = n
-        self.params = arbor.cell_parameters()
 
     # The num_cells method that returns the total number of cells in the model
     # must be implemented.
@@ -18,7 +57,7 @@ class ring_recipe (arbor.recipe):
 
     # The cell_description method returns a cell
     def cell_description(self, gid):
-        return arbor.make_cable_cell(gid, self.params)
+        return make_cable_cell(gid)
 
     def num_targets(self, gid):
         return 1
@@ -35,7 +74,7 @@ class ring_recipe (arbor.recipe):
     def connections_on(self, gid):
         src = (gid-1)%self.ncells
         w = 0.01
-        d = 10
+        d = 5
         return [arbor.connection(arbor.cell_member(src,0), arbor.cell_member(gid,0), w, d)]
 
     # Attach a generator to the first cell in the ring.
@@ -59,7 +98,8 @@ print(context)
 meters = arbor.meter_manager()
 meters.start(context)
 
-recipe = ring_recipe(10)
+ncells = 4
+recipe = ring_recipe(ncells)
 print(f'{recipe}')
 
 meters.checkpoint('recipe-create', context)
@@ -84,37 +124,42 @@ meters.checkpoint('simulation-init', context)
 
 spike_recorder = arbor.attach_spike_recorder(sim)
 
-pid = arbor.cell_member(0,0) # cell 0, probe 0
 # Attach a sampler to the voltage probe on cell 0.
-# Sample rate of 1 sample every ms.
-sampler = arbor.attach_sampler(sim, 1, pid)
+# Sample rate of 10 sample every ms.
+samplers = [arbor.attach_sampler(sim, 0.1, arbor.cell_member(gid,0)) for gid in range(ncells)]
 
-sim.run(100)
+tfinal=1
+sim.run(tfinal)
 print(f'{sim} finished')
 
 meters.checkpoint('simulation-run', context)
 
+# Print profiling information
 print(f'{arbor.meter_report(meters, context)}')
 
+# Print spike times
+print('spikes:')
 for sp in spike_recorder.spikes:
-    print(sp)
+    print(' ', sp)
 
-print('voltage samples for probe id ', end = '')
-print(pid, end = '')
-print(':')
+# Plot the voltage trace at the soma of each cell.
+fig, ax = plt.subplots()
+for gid in range(ncells):
+    times = [s.time  for s in samplers[gid].samples(arbor.cell_member(gid,0))]
+    volts = [s.value for s in samplers[gid].samples(arbor.cell_member(gid,0))]
+    ax.plot(times, volts, '.')
 
-time = []
-value = []
-for sa in sampler.samples(pid):
-    print(sa)
-    time.append(sa.time)
-    value.append(sa.value)
+legends = ['cell {}'.format(gid) for gid in range(ncells)]
+ax.legend(legends)
 
-# plot the recorded voltages over time
-fig, ax = plt.subplots()
-ax.plot(time, value)
 ax.set(xlabel='time (ms)', ylabel='voltage (mV)', title='ring demo')
-ax.legend(['voltage'])
-plt.xlim(0,100)
+plt.xlim(0,tfinal)
+plt.ylim(-80,40)
 ax.grid()
-fig.savefig("voltages.png", dpi=300)
+
+plot_to_file=False
+if plot_to_file:
+    fig.savefig("voltages.png", dpi=300)
+    print('voltage samples saved to voltages.png')
+else:
+    plt.show()
diff --git a/python/example/single_cell_builder.py b/python/example/single_cell_builder.py
new file mode 100644
index 0000000000000000000000000000000000000000..7c7cd7a2d03ec79d87cba51870a5f5b62076af67
--- /dev/null
+++ b/python/example/single_cell_builder.py
@@ -0,0 +1,103 @@
+import arbor
+from arbor import mechanism as mech
+from arbor import location as loc
+import matplotlib.pyplot as plt
+
+# Make a ball and stick cell model
+b = arbor.flat_cell_builder()
+
+# Construct a cell with the following morphology.
+# The soma (at the root of the tree) is marked 's', and
+# the end of each branch i is marked 'bi'.
+#
+#               b5
+#              /
+#             /
+#            b2---b4
+#           /
+#          /
+# s-------b1
+#          \
+#           \
+#            b3
+
+# Add a spherical soma with radius 6 um
+s  = b.add_sphere(6, "soma")
+
+# Add the dendrite cables, labelling those closest to the soma "dendn",
+# and those furthest with "dendx" because we will set different electrical
+# properties for the two regions.
+b1 = b.add_cable(parent=s,  length=100, radius=2, name="dendn", ncomp=100)
+# Radius tapers from 2 to 0.5 over the length of the branch.
+b2 = b.add_cable(parent=b1, length= 50, radius=(2,0.5), name="dendn", ncomp=50)
+b3 = b.add_cable(parent=b1, length= 50, radius=1, name="dendn", ncomp=50)
+b4 = b.add_cable(parent=b2, length= 50, radius=1, name="dendx", ncomp=50)
+b5 = b.add_cable(parent=b2, length= 50, radius=1, name="dendx", ncomp=50)
+
+# Combine the "dendn" and "dendx" regions into a single "dend" region.
+# The dendrites were labelled as such so that we can set different
+# properties on each sub-region, and then combined so that we can
+# set other properties on the whole dendrites.
+b.add_label('dend', '(join (region "dendn") (region "dendx"))')
+# Location of stimuli, in the middle of branch 2.
+b.add_label('stim_site', '(location 2 0.5)')
+# The root of the tree (equivalent to '(location 0 0)')
+b.add_label('root', '(root)')
+
+# Extract the cable cell from the builder.
+cell = b.build()
+
+# Set initial membrane potential everywhere on the cell to -40 mV.
+cell.set_properties(Vm=-40)
+# Put hh dynamics on soma, and passive properties on the dendrites.
+cell.paint('soma', 'hh')
+cell.paint('dend', 'pas')
+# Set axial resistivity in dendrite regions (Ohm.cm)
+cell.paint('dendn', rL=500)
+cell.paint('dendx', rL=10000)
+# Attach stimuli with duration of 2 ms and current of 0.8 nA.
+# There are three stimuli, which activate at 10 ms, 50 ms and 80 ms.
+cell.place('stim_site', arbor.iclamp( 10, 2, 0.8))
+cell.place('stim_site', arbor.iclamp( 50, 2, 0.8))
+cell.place('stim_site', arbor.iclamp( 80, 2, 0.8))
+# Add a spike detector with threshold of -10 mV.
+cell.place('root', arbor.spike_detector(-10))
+
+# make single cell model
+m = arbor.single_cell_model(cell)
+
+# Attach voltage probes, sampling at 10 kHz.
+m.probe('voltage', loc(0,0), 10000) # at the soma
+m.probe('voltage', loc(3,1), 10000) # at the end of branch 3
+m.probe('voltage', loc(4,1), 10000) # at the end of branch 4
+
+# Run simulation for 100 ms of simulated activity.
+tfinal=100
+m.run(tfinal)
+
+# Print spike times.
+if len(m.spikes)>0:
+    print('{} spikes:'.format(len(m.spikes)))
+    for s in m.spikes:
+        print('  {:7.4f}'.format(s))
+else:
+    print('no spikes')
+
+# Plot the recorded voltages over time.
+fig, ax = plt.subplots()
+for t in m.traces:
+    ax.plot(t.time, t.value)
+
+legend_labels = ['{}: {}'.format(s.variable, s.location) for s in m.traces]
+ax.legend(legend_labels)
+ax.set(xlabel='time (ms)', ylabel='voltage (mV)', title='cell builder demo')
+plt.xlim(0,tfinal)
+plt.ylim(-80,50)
+ax.grid()
+
+# Set to True to save the image to file instead of opening a plot window.
+plot_to_file=False
+if plot_to_file:
+    fig.savefig("voltages.png", dpi=300)
+else:
+    plt.show()
diff --git a/python/example/single_cell_swc.py b/python/example/single_cell_swc.py
new file mode 100644
index 0000000000000000000000000000000000000000..951f502f311a90bcd5f9db3f35a79277c69770db
--- /dev/null
+++ b/python/example/single_cell_swc.py
@@ -0,0 +1,78 @@
+import arbor
+from arbor import mechanism as mech
+from arbor import location as loc
+import matplotlib.pyplot as plt
+
+# Load a cell morphology from an swc file.
+# The model has 31 branches, including soma, dendrites and axon.
+tree = arbor.load_swc('../../test/unit/swc/example.swc')
+
+# Define the regions and locsets in the model.
+defs = {'soma': '(tag 1)',  # soma has tag 1 in swc files.
+        'axon': '(tag 2)',  # axon has tag 1 in swc files.
+        'dend': '(tag 3)',  # dendrites have tag 1 in swc files.
+        'root': '(root)',   # the start of the soma in this morphology is at the root of the cell.
+        'stim_site': '(location 1 0.5)'} # site for the stimulus, in the middle of branch 1.
+labels = arbor.label_dict(defs)
+
+# Combine morphology with region and locset definitions to make a cable cell.
+cell = arbor.cable_cell(tree, labels)
+
+# Set initial membrane potential to -55 mV
+cell.set_properties(Vm=-55)
+# Use Nernst to calculate reversal potential for calcium.
+cell.set_ion('ca', method=mech('nernst/x=ca'))
+# hh mechanism on the soma and axon.
+cell.paint('soma', 'hh')
+cell.paint('axon', 'hh')
+# pas mechanism the dendrites.
+cell.paint('dend', 'pas')
+# Increase resistivity on dendrites.
+cell.paint('dend', rL=500)
+# Attach stimuli that inject 0.8 nA currents for 1 ms, starting at 3 and 8 ms.
+cell.place('stim_site', arbor.iclamp(3, 1, current=0.8))
+cell.place('stim_site', arbor.iclamp(8, 1, 0.8))
+# Detect spikes at the soma with a voltage threshold of -10 mV.
+cell.place('root', arbor.spike_detector(-10))
+
+# Have one compartment between each sample point.
+cell.compartments_on_samples()
+
+# Make single cell model.
+m = arbor.single_cell_model(cell)
+
+# Attach voltage probes that sample at 50 kHz.
+m.probe('voltage', where=loc(0,0),  frequency=50000)
+m.probe('voltage', where=loc(2,1),  frequency=50000)
+m.probe('voltage', where=loc(4,1),  frequency=50000)
+m.probe('voltage', where=loc(30,1), frequency=50000)
+
+# Simulate the cell for 15 ms.
+tfinal=15
+m.run(tfinal)
+
+# Print spike times.
+if len(m.spikes)>0:
+    print('{} spikes:'.format(len(m.spikes)))
+    for s in m.spikes:
+        print('  {:7.4f}'.format(s))
+else:
+    print('no spikes')
+
+# Plot the recorded voltages over time.
+fig, ax = plt.subplots()
+for t in m.traces:
+    ax.plot(t.time, t.value)
+
+legend_labels = ['{}: {}'.format(s.variable, s.location) for s in m.traces]
+ax.legend(legend_labels)
+ax.set(xlabel='time (ms)', ylabel='voltage (mV)', title='swc morphology demo')
+plt.xlim(0,tfinal)
+plt.ylim(-80,50)
+ax.grid()
+
+plot_to_file=False
+if plot_to_file:
+    fig.savefig("voltages.png", dpi=300)
+else:
+    plt.show()
diff --git a/python/flat_cell_builder.cpp b/python/flat_cell_builder.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..53662f86b25a948062f5548d4d1380cff52c1a99
--- /dev/null
+++ b/python/flat_cell_builder.cpp
@@ -0,0 +1,268 @@
+#include <pybind11/pybind11.h>
+#include <pybind11/stl.h>
+
+#include <arbor/cable_cell.hpp>
+#include <arbor/morph/morphology.hpp>
+#include <arbor/morph/primitives.hpp>
+#include <arbor/morph/sample_tree.hpp>
+
+#include "conversion.hpp"
+#include "error.hpp"
+#include "morph_parse.hpp"
+#include "s_expr.hpp"
+#include "strprintf.hpp"
+
+namespace pyarb {
+
+class flat_cell_builder {
+    // The sample tree describing the morphology: constructed on the fly as
+    // the cables/spheres are added with add_cable/add_sphere.
+    arb::sample_tree tree_;
+
+    // The distal sample id of each cable.
+    std::vector<arb::msize_t> cable_distal_id_;
+
+    // The number of unique region names used to label cables as they
+    // are added to the cell.
+    int tag_count_ = 0;
+    // Map from region names to the tag used to identify them.
+    std::unordered_map<std::string, int> tag_map_;
+
+    arb::label_dict dict_;
+
+    // The morphology is cached, and only updated on request when it is out of date.
+    mutable bool cached_morpho_ = true;
+    mutable arb::morphology morpho_;
+    mutable std::mutex mutex_;
+
+    // Set on construction and unchanged thereafter.
+    // Indicates whether 
+    bool spherical_ = false;
+
+public:
+
+    flat_cell_builder() = default;
+
+    arb::msize_t add_sphere(double radius, const char* name) {
+        cached_morpho_ = false;
+        spherical_ = true;
+        if (size()) {
+            throw pyarb_error("Add soma to non-empty cell.");
+        }
+        tree_.append({{0,0,0,radius}, get_tag(name)});
+        cable_distal_id_.push_back(0);
+        return 0;
+    }
+
+    // Add a new branch that is attached to parent.
+    // Returns the id of the new cable.
+    arb::msize_t add_cable(arb::msize_t parent, double len,
+                           double r1, double r2, const char* region, int ncomp)
+    {
+        using arb::mnpos;
+
+        cached_morpho_ = false;
+
+        if (!test_identifier(region)) {
+            throw pyarb_error(util::pprintf("'{}' is not a valid label name.", region));
+        }
+
+        // Get tag id of region (add a new tag if region does not already exist).
+        int tag = get_tag(region);
+        const bool at_root = parent==mnpos;
+
+        // Can't attach a cable to the root on a cell with spherical soma.
+        if (at_root && spherical_) {
+            throw pyarb_error("Invalid parent id.");
+        }
+        // Parent id must be in the range [0, size())
+        if (!at_root && parent>=size()) {
+            throw pyarb_error("Invalid parent id.");
+        }
+
+        // Calculating the sample that is the parent of this branch is
+        // neccesarily complicated to handle cable segments that are attached
+        // to the root.
+        arb::msize_t p = at_root? (size()? 0: mnpos): cable_distal_id_[parent];
+
+        double z = at_root? 0:                      // attach to root of non-spherical cell
+                   spherical_&&!parent? soma_rad(): // attach to spherical root
+                   tree_.samples()[p].loc.z;        // attach to end of a cable
+
+        // Only add a first point at the very beginning of the cable if
+        // the cable is not attached to another
+        const bool add_first_point = p==arb::mnpos      // attached to the "root"
+                                  || (!p && spherical_) // attached to a spherical root
+                                  || (r1!=tree_.samples()[p].loc.radius);
+                                                        // proximal radius does not match r1
+        if (add_first_point) {
+            p = tree_.append(p, {{0,0,z,r1}, tag});
+        }
+        if (ncomp>1) {
+            double dz = len/ncomp;
+            double dr = (r2-r1)/ncomp;
+            for (auto i=1; i<ncomp; ++i) {
+                p = tree_.append(p, {{0,0,z+i*dz, r1+i*dr}, tag});
+            }
+        }
+        p = tree_.append(p, {{0,0,z+len,r2}, tag});
+        cable_distal_id_.push_back(p);
+
+        return size()-1;
+    }
+
+    void add_label(const char* name, const char* description) {
+        if (!test_identifier(name)) {
+            throw pyarb_error(util::pprintf("'{}' is not a valid label name.", name));
+        }
+
+        if (auto result = eval(parse(description)) ) {
+            // The description is a region.
+            if (result->type()==typeid(arb::region)) {
+                if (dict_.locset(name)) {
+                    throw pyarb_error("Region name clashes with a locset.");
+                }
+                auto& reg = arb::util::any_cast<arb::region&>(*result);
+                if (auto r = dict_.region(name)) {
+                    dict_.set(name, join(std::move(reg), std::move(*r)));
+                }
+                else {
+                    dict_.set(name, std::move(reg));
+                }
+            }
+            else if (result->type()==typeid(arb::locset)) {
+                if (dict_.region(name)) {
+                    throw pyarb_error("Locset name clashes with a region.");
+                }
+                auto& loc = arb::util::any_cast<arb::locset&>(*result);
+                if (auto l = dict_.locset(name)) {
+                    dict_.set(name, sum(std::move(loc), std::move(*l)));
+                }
+                else {
+                    dict_.set(name, std::move(loc));
+                }
+            }
+            else {
+                throw pyarb_error("Label describes neither a region nor a locset.");
+            }
+        }
+        else {
+            throw pyarb_error(result.error().message);
+        }
+    }
+
+    const arb::sample_tree& samples() const {
+        return tree_;
+    }
+
+    std::unordered_map<std::string, std::string> labels() const {
+        std::unordered_map<std::string, std::string> map;
+        for (auto& r: dict_.regions()) {
+            map[r.first] = util::pprintf("{}", r.second);
+        }
+        for (auto& l: dict_.locsets()) {
+            map[l.first] = util::pprintf("{}", l.second);
+        }
+
+        return map;
+    }
+
+    const arb::morphology& morphology() const {
+        const std::lock_guard<std::mutex> guard(mutex_);
+        if (!cached_morpho_) {
+            morpho_ = arb::morphology(tree_, spherical_);
+            cached_morpho_ = true;
+        }
+        return morpho_;
+    }
+
+    arb::cable_cell build() const {
+        // Make cable_cell from sample tree and dictionary.
+        auto c = arb::cable_cell(morphology(), dict_);
+        c.default_parameters.discretization = arb::cv_policy_every_sample{};
+        return c;
+    }
+
+    private:
+
+    // Get tag id of region with name.
+    // Add a new tag if region with that name has not already had a tag associated with it.
+    int get_tag(const std::string& name) {
+        using arb::reg::tagged;
+
+        // Name is in the map: return the tag.
+        auto it = tag_map_.find(name);
+        if (it!=tag_map_.end()) {
+            return it->second;
+        }
+        // If the name is not in the map, the next step depends on
+        // whether the name is used for a locst, or a region that is
+        // not in the map, or is not used at all.
+
+        // Name is a locset: error.
+        if (dict_.locset(name)) {
+            throw pyarb_error(util::pprintf("'{}' is a label for a locset."));
+        }
+        // Name is a region: add tag to region definition.
+        else if(auto reg = dict_.region(name)) {
+            tag_map_[name] = ++tag_count_;
+            dict_.set(name, join(*reg, tagged(tag_count_)));
+            return tag_count_;
+        }
+        // Name has not been registerd: make a unique tag and new region.
+        else {
+            tag_map_[name] = ++tag_count_;
+            dict_.set(name, tagged(tag_count_));
+            return tag_count_;
+        }
+    }
+
+    // Only valid if called on a non-empty tree with spherical soma.
+    double soma_rad() const {
+        return tree_.samples()[0].loc.radius;
+    }
+
+    // The number of cable segements (plus one optional soma) used to construct the cell.
+    std::size_t size() const {
+        return cable_distal_id_.size();
+    }
+};
+
+void register_flat_builder(pybind11::module& m) {
+    using namespace pybind11::literals;
+
+    pybind11::class_<flat_cell_builder> builder(m, "flat_cell_builder");
+    builder
+        .def(pybind11::init<>())
+        .def("add_sphere", &flat_cell_builder::add_sphere,
+            "radius"_a, "name"_a)
+        .def("add_cable",
+                [](flat_cell_builder& b, arb::msize_t p, double len, pybind11::object rad, const char* name, int ncomp) {
+                    using pybind11::isinstance;
+                    using pybind11::cast;
+                    if (auto radius = try_cast<double>(rad) ) {
+                        return b.add_cable(p, len, *radius, *radius, name, ncomp);
+                    }
+
+                    if (auto radii = try_cast<std::pair<double, double>>(rad)) {
+                        return b.add_cable(p, len, radii->first, radii->second, name, ncomp);
+                    }
+                    else {
+                        throw pyarb_error(
+                            "Radius parameter is not a scalar (constant branch radius) or "
+                            "a tuple (radius at proximal and distal ends respectively).");
+                    }
+                },
+            "parent"_a, "length"_a, "radius"_a, "name"_a, "ncomp"_a=1)
+        .def("add_label", &flat_cell_builder::add_label,
+            "name"_a, "description"_a)
+        .def_property_readonly("samples",
+            [](const flat_cell_builder& b) { return b.samples(); })
+        .def_property_readonly("labels",
+            [](const flat_cell_builder& b) { return b.labels(); })
+        .def_property_readonly("morphology",
+            [](const flat_cell_builder& b) { return b.morphology(); })
+        .def("build", &flat_cell_builder::build);
+}
+
+} // namespace pyarb
diff --git a/python/morph_parse.cpp b/python/morph_parse.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..bef6fef2b5c650d86cdbb5209deb0b41f810f70d
--- /dev/null
+++ b/python/morph_parse.cpp
@@ -0,0 +1,349 @@
+#include <arbor/util/any.hpp>
+#include <arbor/morph/region.hpp>
+#include <arbor/morph/locset.hpp>
+
+#include "error.hpp"
+#include "s_expr.hpp"
+#include "morph_parse.hpp"
+
+namespace pyarb {
+
+struct nil_tag {};
+
+template <typename T>
+bool match(const std::type_info& info) {
+    return info == typeid(T);
+}
+
+template <>
+bool match<double>(const std::type_info& info) {
+    return info == typeid(double) || info == typeid(int);
+}
+
+template <>
+bool match<arb::region>(const std::type_info& info) {
+    return info == typeid(arb::region) || info == typeid(nil_tag);
+}
+
+template <>
+bool match<arb::locset>(const std::type_info& info) {
+    return info == typeid(arb::locset) || info == typeid(nil_tag);
+}
+
+template <typename T>
+T eval_cast(arb::util::any arg) {
+    return std::move(arb::util::any_cast<T&>(arg));
+}
+
+template <>
+double eval_cast<double>(arb::util::any arg) {
+    if (arg.type()==typeid(int)) return arb::util::any_cast<int>(arg);
+    return arb::util::any_cast<double>(arg);
+}
+
+template <>
+arb::region eval_cast<arb::region>(arb::util::any arg) {
+    if (arg.type()==typeid(arb::region)) return arb::util::any_cast<arb::region>(arg);
+    return arb::reg::nil();
+}
+
+template <>
+arb::locset eval_cast<arb::locset>(arb::util::any arg) {
+    if (arg.type()==typeid(arb::locset)) return arb::util::any_cast<arb::locset>(arg);
+    return arb::ls::nil();
+}
+
+template <typename... Args>
+struct call_eval {
+    using ftype = std::function<arb::util::any(Args...)>;
+    ftype f;
+    call_eval(ftype f): f(std::move(f)) {}
+
+    template<std::size_t... I>
+    arb::util::any expand_args_then_eval(std::vector<arb::util::any> args, std::index_sequence<I...>) {
+        return f(eval_cast<Args>(std::move(args[I]))...);
+    }
+
+    arb::util::any operator()(std::vector<arb::util::any> args) {
+        return expand_args_then_eval(std::move(args), std::make_index_sequence<sizeof...(Args)>());
+    }
+};
+
+template <typename... Args>
+struct call_match {
+    template <std::size_t I, typename T, typename Q, typename... Rest>
+    bool match_args_impl(const std::vector<arb::util::any>& args) const {
+        return match<T>(args[I].type()) && match_args_impl<I+1, Q, Rest...>(args);
+    }
+
+    template <std::size_t I, typename T>
+    bool match_args_impl(const std::vector<arb::util::any>& args) const {
+        return match<T>(args[I].type());
+    }
+
+    template <std::size_t I>
+    bool match_args_impl(const std::vector<arb::util::any>& args) const {
+        return true;
+    }
+
+    bool operator()(const std::vector<arb::util::any>& args) const {
+        const auto nargs_in = args.size();
+        const auto nargs_ex = sizeof...(Args);
+        return nargs_in==nargs_ex? match_args_impl<0, Args...>(args): false;
+    }
+};
+
+template <typename T>
+struct fold_eval {
+    using fold_fn = std::function<T(T, T)>;
+    fold_fn f;
+
+    using anyvec = std::vector<arb::util::any>;
+    using iterator = anyvec::iterator;
+
+    fold_eval(fold_fn f): f(std::move(f)) {}
+
+    T fold_impl(iterator left, iterator right) {
+        if (std::distance(left,right)==1u) {
+            return eval_cast<T>(std::move(*left));
+        }
+        return f(eval_cast<T>(std::move(*left)), fold_impl(left+1, right));
+    }
+
+    arb::util::any operator()(anyvec args) {
+        return fold_impl(args.begin(), args.end());
+    }
+};
+
+template <typename T>
+struct fold_match {
+    using anyvec = std::vector<arb::util::any>;
+    bool operator()(const anyvec& args) const {
+        if (args.size()<2u) return false;
+        for (auto& a: args) {
+            if (!match<T>(a.type())) return false;
+        }
+        return true;
+    }
+};
+
+struct evaluator {
+    using any_vec = std::vector<arb::util::any>;
+    using eval_fn = std::function<arb::util::any(any_vec)>;
+    using args_fn = std::function<bool(const any_vec&)>;
+
+    eval_fn eval;
+    args_fn match_args;
+    const char* message;
+
+    evaluator(eval_fn f, args_fn a, const char* m):
+        eval(std::move(f)),
+        match_args(std::move(a)),
+        message(m)
+    {}
+};
+
+template <typename... Args>
+struct make_call {
+    evaluator state;
+
+    template <typename F>
+    make_call(F&& f, const char* msg="call"):
+        state(call_eval<Args...>(std::forward<F>(f)), call_match<Args...>(), msg)
+    {}
+
+    operator evaluator() const {
+        return state;
+    }
+};
+
+template <typename T>
+struct make_fold {
+    evaluator state;
+
+    template <typename F>
+    make_fold(F&& f, const char* msg="fold"):
+        state(fold_eval<T>(std::forward<F>(f)), fold_match<T>(), msg)
+    {}
+
+    operator evaluator() const {
+        return state;
+    }
+};
+
+std::unordered_multimap<std::string, evaluator> eval_map {
+    // Functions that return regions
+    {"nil",     make_call<>(arb::reg::nil,
+                            "'nil' with 0 arguments")},
+    {"all",     make_call<>(arb::reg::all,
+                            "'all' with 0 arguments")},
+    {"tag",     make_call<int>(arb::reg::tagged,
+                            "'tag' with 1 argment: (tag_id:integer)")},
+    {"branch",  make_call<int>(arb::reg::branch,
+                            "'branch' with 1 argument: (branch_id:integer)")},
+    {"cable",   make_call<int, double, double>(arb::reg::cable,
+                            "'cable' with 3 arguments: (branch_id:integer prox:real dist:real)")},
+    {"region",  make_call<std::string>(arb::reg::named,
+                            "'region' with 1 argument: (name:string)")},
+    {"distal_interval",  make_call<arb::locset, double>(arb::reg::distal_interval,
+                            "'distal_interval' with 2 arguments: (start:locset extent:real)")},
+    {"proximal_interval",make_call<arb::locset, double>(arb::reg::proximal_interval,
+                            "'proximal_interval' with 2 arguments: (start:locset extent:real)")},
+    {"radius_lt",make_call<arb::region, double>(arb::reg::radius_lt,
+                            "'radius_lt' with 2 arguments: (reg:region radius:real)")},
+    {"radius_le",make_call<arb::region, double>(arb::reg::radius_le,
+                            "'radius_le' with 2 arguments: (reg:region radius:real)")},
+    {"radius_gt",make_call<arb::region, double>(arb::reg::radius_gt,
+                            "'radius_gt' with 2 arguments: (reg:region radius:real)")},
+    {"radius_ge",make_call<arb::region, double>(arb::reg::radius_ge,
+                            "'radius_ge' with 2 arguments: (reg:region radius:real)")},
+    {"join",    make_fold<arb::region>(static_cast<arb::region(*)(arb::region, arb::region)>(arb::join),
+                            "'join' with at least 2 arguments: (region region [...region])")},
+    {"intersect",make_fold<arb::region>(static_cast<arb::region(*)(arb::region, arb::region)>(arb::intersect),
+                            "'intersect' with at least 2 arguments: (region region [...region])")},
+
+    // Functions that return locsets
+    {"root",    make_call<>(arb::ls::root,
+                            "'root' with 0 arguments")},
+
+    {"location", make_call<int, double>([](int bid, double pos){return arb::ls::location(arb::msize_t(bid), pos);},
+                            "'location' with 2 arguments: (branch_id:integer position:real)")},
+    {"terminal", make_call<>(arb::ls::terminal,
+                            "'terminal' with 0 arguments")},
+    {"sample",  make_call<int>(arb::ls::sample,
+                            "'sample' with 1 argument: (sample_id:integer)")},
+    {"distal",  make_call<arb::region>(arb::ls::most_distal,
+                            "'distal' with 1 argument: (reg:region)")},
+    {"proximal",make_call<arb::region>(arb::ls::most_proximal,
+                            "'proximal' with 1 argument: (reg:region)")},
+    {"uniform",make_call<arb::region, int, int, int>(arb::ls::uniform,
+                            "'uniform' with 4 arguments: (reg:region, first:int, last:int, seed:int)")},
+    {"on_branches",make_call<double>(arb::ls::on_branches,
+                            "'on_branches' with 1 argument: (pos:double)")},
+    {"locset",  make_call<std::string>(arb::ls::named,
+                            "'locset' with 1 argument: (name:string)")},
+    {"join",    make_fold<arb::locset>(static_cast<arb::locset(*)(arb::locset, arb::locset)>(arb::join),
+                            "'join' with at least 2 arguments: (locset locset [...locset])")},
+    {"sum",     make_fold<arb::locset>(static_cast<arb::locset(*)(arb::locset, arb::locset)>(arb::sum),
+                            "'sum' with at least 2 arguments: (locset locset [...locset])")},
+};
+
+parse_hopefully<arb::util::any> eval(const s_expr& e);
+
+parse_hopefully<std::vector<arb::util::any>> eval_args(const s_expr& e) {
+    if (!e) return {std::vector<arb::util::any>{}}; // empty argument list
+    const s_expr* h = &e;
+    std::vector<arb::util::any> args;
+    while (*h) {
+        auto arg = eval(h->head());
+        if (!arg) return std::move(arg.error());
+        args.push_back(std::move(*arg));
+        h = &h->tail();
+    }
+    return args;
+}
+
+// Generate a string description of a function evaluation of the form:
+// Example output:
+//  'foo' with 1 argument: (real)
+//  'bar' with 0 arguments
+//  'cat' with 3 arguments: (locset region integer)
+// Where 'foo', 'bar' and 'cat' are the name of the function, and the
+// types (integer, real, region, locset) are inferred from the arguments.
+std::string eval_description(const char* name, const std::vector<arb::util::any>& args) {
+    auto type_string = [](const std::type_info& t) -> const char* {
+        if (t==typeid(int))         return "integer";
+        if (t==typeid(double))      return "real";
+        if (t==typeid(arb::region)) return "region";
+        if (t==typeid(arb::locset)) return "locset";
+        if (t==typeid(nil_tag))     return "()";
+        return "unknown";
+    };
+
+    const auto nargs = args.size();
+    std::string msg =
+        util::pprintf("'{}' with {} argument{}",
+                      name, nargs,
+                      nargs==0?"s": nargs==1u?":": "s:");
+    if (nargs) {
+        msg += " (";
+        bool first = true;
+        for (auto& a: args) {
+            msg += util::pprintf("{}{}", first?"":" ", type_string(a.type()));
+            first = false;
+        }
+        msg += ")";
+    }
+    return msg;
+}
+
+// Evaluate an s expression.
+// On success the result is wrapped in util::any, where the result is one of:
+//      int         : an integer atom
+//      double      : a real atom
+//      arb::region : a region
+//      arb::locset : a locset
+//
+// If there invalid input is detected, hopefully return value contains
+// a parse_error_state with an error string and location.
+//
+// If there was an unexpected/fatal error, an exception will be thrown.
+parse_hopefully<arb::util::any> eval(const s_expr& e) {
+    if (e.is_atom()) {
+        auto& t = e.atom();
+        switch (t.kind) {
+            case tok::integer:
+                return {std::stoi(t.spelling)};
+            case tok::real:
+                return {std::stod(t.spelling)};
+            case tok::nil:
+                return {nil_tag()};
+            case tok::string:
+                return {std::string(t.spelling)};
+            case tok::error:
+                return parse_error_state{e.atom().spelling, location(e)};
+            default:
+                return parse_error_state{
+                    util::pprintf("Unexpected term: {}", e), location(e)};
+        }
+    }
+    if (e.head().is_atom()) {
+        // This must be a function evaluation, where head is the function name, and
+        // tail is a list of arguments.
+
+        // Evaluate the arguments, and return error state if an error ocurred.
+        auto args = eval_args(e.tail());
+        if (!args) {
+            return args.error();
+        }
+
+        // Find all candidate functions that match the name of the function.
+        auto& name = e.head().atom().spelling;
+        auto matches = eval_map.equal_range(name);
+
+        // Search for a candidate that matches the argument list.
+        for (auto i=matches.first; i!=matches.second; ++i) {
+            if (i->second.match_args(*args)) { // found a match: evaluate and return.
+                return i->second.eval(*args);
+            }
+        }
+
+        // Unable to find a match: try to return a helpful error message.
+        const auto nc = std::distance(matches.first, matches.second);
+        auto msg = util::pprintf("No matches for {}", eval_description(name.c_str(), *args));
+        msg += util::pprintf("\n  There are {} potential candiates{}", nc, nc?":":".");
+        int count = 0;
+        for (auto i=matches.first; i!=matches.second; ++i) {
+            msg += util::pprintf("\n  Candidate {}  {}", ++count, i->second.message);
+        }
+        return parse_error_state{std::move(msg), location(e)};
+    }
+
+    return parse_error_state{
+        util::pprintf("Unable to evaluate '{}': expression must be either integer, real expression of the form (op <args>)", e),
+            location(e)};
+}
+
+} // namespace pyarb
+
+
diff --git a/python/morph_parse.hpp b/python/morph_parse.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..cb78cb57ee20f9f688e460201b70c44f3899d8f9
--- /dev/null
+++ b/python/morph_parse.hpp
@@ -0,0 +1,20 @@
+#pragma once
+
+#include "error.hpp"
+#include "s_expr.hpp"
+
+#include <arbor/util/any.hpp>
+
+namespace pyarb {
+
+struct parse_error_state {
+    std::string message;
+    int location;
+};
+
+template <typename T>
+using parse_hopefully = hopefully<T, parse_error_state>;
+
+parse_hopefully<arb::util::any> eval(const s_expr&);
+
+} // namespace pyarb
diff --git a/python/morphology.cpp b/python/morphology.cpp
index 6b995db640c0bdd79d71bc519cb1993c0fc1bb0a..0e503bf52701f225b0530d2ec1312b1a2f7d0a84 100644
--- a/python/morphology.cpp
+++ b/python/morphology.cpp
@@ -1,6 +1,12 @@
 #include <pybind11/pybind11.h>
+#include <pybind11/stl.h>
 
+#include <fstream>
+
+#include <arbor/morph/morphology.hpp>
 #include <arbor/morph/primitives.hpp>
+#include <arbor/morph/sample_tree.hpp>
+#include <arbor/swcio.hpp>
 
 #include "error.hpp"
 #include "strprintf.hpp"
@@ -10,7 +16,13 @@ namespace pyarb {
 void register_morphology(pybind11::module& m) {
     using namespace pybind11::literals;
 
-    // segment location
+    //
+    //  primitives: points, samples, locations, cables... etc.
+    //
+
+    m.attr("mnpos") = arb::mnpos;
+
+    // arb::mlocation
     pybind11::class_<arb::mlocation> location(m, "location",
         "A location on a cable cell.");
     location
@@ -24,10 +36,178 @@ void register_morphology(pybind11::module& m) {
             "Construct a location specification holding:\n"
             "  branch:   The id of the branch.\n"
             "  position: The relative position (from 0., proximal, to 1., distal) on the branch.\n")
-        .def_readonly("branch",  &arb::mlocation::branch,  "The id of the branch.")
-        .def_readonly("position", &arb::mlocation::pos, "The relative position on the branch (∈ [0.,1.], where 0. means proximal and 1. distal).")
-        .def("__str__", [](arb::mlocation l) {return util::pprintf("<arbor.location: branch {}, position {}>", l.branch, l.pos);})
-        .def("__repr__", [](arb::mlocation l) {return util::pprintf("<arbor.location: branch {}, position {}>", l.branch, l.pos);});
+        .def_readonly("branch",  &arb::mlocation::branch,
+            "The id of the branch.")
+        .def_readonly("position", &arb::mlocation::pos,
+            "The relative position on the branch (∈ [0.,1.], where 0. means proximal and 1. distal).")
+        .def("__str__",
+            [](arb::mlocation l) {
+                return util::pprintf("(location {} {})", l.branch, l.pos);
+            })
+        .def("__repr__",
+            [](arb::mlocation l) {
+                return util::pprintf("<arbor.location: branch {}, position {}>", l.branch, l.pos);
+            });
+
+    // arb::mpoint
+    pybind11::class_<arb::mpoint> mpoint(m, "mpoint");
+    mpoint
+        .def(pybind11::init(
+                [](double x, double y, double z, double r) {
+                    return arb::mpoint{x,y,z,r};
+                }),
+                "x"_a, "y"_a, "z"_a, "radius"_a, "All values in μm.")
+        .def_readonly("x", &arb::mpoint::x, "X coordinate [μm].")
+        .def_readonly("y", &arb::mpoint::y, "Y coordinate [μm].")
+        .def_readonly("z", &arb::mpoint::z, "Z coordinate [μm].")
+        .def_readonly("radius", &arb::mpoint::radius,
+            "Radius of cable at sample location centered at coordinates [μm].")
+        .def("__str__",
+            [](const arb::mpoint& p) {
+                return util::pprintf("<arbor.mpoint: x {}, y {}, z {}, radius {}>", p.x, p.y, p.z, p.radius);
+            })
+        .def("__repr__",
+            [](const arb::mpoint& p) {return util::pprintf("{}>", p);});
 
+    // arb::msample
+    pybind11::class_<arb::msample> msample(m, "msample");
+    msample
+        .def(pybind11::init(
+            [](arb::mpoint loc, int tag){
+                return arb::msample{loc, tag};}),
+            "location"_a, "tag"_a)
+        .def(pybind11::init(
+            [](double x, double y, double z, double r, int tag){
+                return arb::msample{{x,y,z,r}, tag};}),
+            "x"_a, "y"_a, "z"_a, "radius"_a, "tag"_a,
+            "spatial values {x, y, z, radius} are in μm.")
+        .def_readonly("loc", &arb::msample::loc,
+            "Location of sample.")
+        .def_readonly("tag", &arb::msample::tag,
+            "Property tag of sample. "
+            "If loaded from standard SWC file the following tags are used: soma=1, axon=2, dendrite=3, apical dendrite=4, however arbitrary tags can be used.")
+        .def("__str__",
+            [](const arb::msample& s) {
+                return util::pprintf("<arbor.mpoint: x {}, y {}, z {}, radius {}, tag {}>",
+                        s.loc.x, s.loc.y, s.loc.z, s.loc.radius, s.tag); })
+        .def("__repr__",
+            [](const arb::msample& s) {
+                return util::pprintf("{}>", s);});
+
+    // arb::mcable
+    pybind11::class_<arb::mcable> cable(m, "cable");
+    cable
+        .def(pybind11::init(
+                    [](arb::msize_t bid, double prox, double dist) {
+                        arb::mcable c{bid, prox, dist};
+                        if (!test_invariants(c)) {
+                            throw pyarb_error("Invalid cable description. Cable segments must have proximal and distal end points in the range [0,1].");
+                        }
+                        return c;
+                    }),
+             "branch_id"_a, "prox"_a, "dist"_a)
+        .def_readonly("prox_pos", &arb::mcable::prox_pos,
+                "The relative position of the proximal end of the cable on its branch ∈ [0,1].")
+        .def_readonly("dist_pos", &arb::mcable::dist_pos,
+                "The relative position of the distal end of the cable on its branch ∈ [0,1].")
+        .def_readonly("branch", &arb::mcable::branch,
+                "The id of the branch on which the cable lies.")
+        .def("__str__", [](const arb::mcable& c) {
+            return util::pprintf("<arbor.cable: branch {}, prox {}, dist {}", c.branch, c.prox_pos, c.dist_pos); })
+        .def("__repr__", [](const arb::mcable& c) { return util::pprintf("{}", c); });
+
+    //
+    // Higher-level data structures (sample_tree, morphology)
+    //
+
+    // arb::sample_tree
+    pybind11::class_<arb::sample_tree> sample_tree(m, "sample_tree");
+    sample_tree
+        // constructors
+        .def(pybind11::init<>())
+        // modifiers
+        .def("reserve", &arb::sample_tree::reserve)
+        .def("append", [](arb::sample_tree& t, arb::msample s){return t.append(s);})
+        .def("append", [](arb::sample_tree& t, arb::msize_t p, arb::msample s){return t.append(p, s);})
+        // properties
+        .def_property_readonly("empty", [](const arb::sample_tree& st){return st.empty();},
+                "Indicates whether the sample tree is empty (i.e. whether it has size 0)")
+        .def_property_readonly("size", [](const arb::sample_tree& st){return st.size();},
+                "The number of samples in the sample tree.")
+        .def_property_readonly("parents", [](const arb::sample_tree& st){return st.parents();},
+                "A list with the parent index of each sample.")
+        .def("__str__", [](const arb::sample_tree& s) {
+                return util::pprintf("<arbor.sample_tree:\n{}>", s);});
+
+    // Function that creates a sample_tree from an swc file.
+    // Wraps calls to C++ functions arb::parse_swc_file() and arb::swc_as_sample_tree().
+    m.def("load_swc",
+        [](std::string fname) {
+            std::ifstream fid{fname};
+            if (!fid.good()) {
+                throw pyarb_error(util::pprintf("can't open file '{}'", fname));
+            }
+            try {
+                auto records = arb::parse_swc_file(fid);
+                arb::swc_canonicalize(records);
+                return arb::swc_as_sample_tree(records);
+            }
+            catch (arb::swc_error& e) {
+                // Try to produce helpful error messages for SWC parsing errors.
+                throw pyarb_error(
+                    util::pprintf("error parsing line {} of '{}': {}.",
+                                  e.line_number, fname, e.what()));
+            }
+        },
+        "Load an swc file and convert to a sample_tree.");
+
+    // arb::morphology
+
+    pybind11::class_<arb::morphology> morph(m, "morphology");
+    morph
+        // constructors
+        .def(pybind11::init(
+                [](arb::sample_tree t){
+                    return arb::morphology(std::move(t));
+                }))
+        .def(pybind11::init(
+                [](arb::sample_tree t, bool spherical_root) {
+                    return arb::morphology(std::move(t), spherical_root);
+                }), "sample_tree"_a, "spherical_root"_a)
+        // morphology's interface is read-only by design, so most of it can
+        // be implemented as read-only properties.
+        .def_property_readonly("empty",
+                [](const arb::morphology& m){return m.empty();},
+                "A list with the parent index of each sample.")
+        .def_property_readonly("spherical_root",
+                [](const arb::morphology& m){return m.spherical_root();},
+                "Whether the root of the morphology is spherical.")
+        .def_property_readonly("num_branches",
+                [](const arb::morphology& m){return m.num_branches();},
+                "The number of branches in the morphology.")
+        .def_property_readonly("num_samples",
+                [](const arb::morphology& m){return m.num_samples();},
+                "The number of samples in the morphology.")
+        .def_property_readonly("samples",
+                [](const arb::morphology& m){return m.samples();},
+                "All of the samples in the morphology.")
+        .def_property_readonly("sample_parents",
+                [](const arb::morphology& m){return m.sample_parents();},
+                "The parent indexes of each sample.")
+        .def("branch_parent", &arb::morphology::branch_parent,
+                "i"_a, "The parent branch of branch i.")
+        .def("branch_children", &arb::morphology::branch_children,
+                "i"_a, "The child branches of branch i.")
+        .def("branch_indexes",
+                [](const arb::morphology& m, arb::msize_t i) {
+                    auto p = m.branch_indexes(i);
+                    return std::vector<arb::msize_t>(p.first, p.second);
+                },
+                "i"_a, "Range of indexes into the sample points in branch i.")
+        .def("__str__",
+                [](const arb::morphology& m) {
+                    return util::pprintf("<arbor.morphology:\n{}>", m);
+                });
 }
+
 } // namespace pyarb
diff --git a/python/pyarb.cpp b/python/pyarb.cpp
index e17cbdfd919c6c68e948d6a4ad5b4c1cbc191703..bab7005fdc227c6188e3ba6593873a13cc539b23 100644
--- a/python/pyarb.cpp
+++ b/python/pyarb.cpp
@@ -11,6 +11,7 @@ void register_config(pybind11::module& m);
 void register_contexts(pybind11::module& m);
 void register_domain_decomposition(pybind11::module& m);
 void register_event_generators(pybind11::module& m);
+void register_flat_builder(pybind11::module& m);
 void register_identifiers(pybind11::module& m);
 void register_morphology(pybind11::module& m);
 void register_profiler(pybind11::module& m);
@@ -18,6 +19,7 @@ void register_recipe(pybind11::module& m);
 void register_sampling(pybind11::module& m);
 void register_schedules(pybind11::module& m);
 void register_simulation(pybind11::module& m);
+void register_single_cell(pybind11::module& m);
 void register_spike_handling(pybind11::module& m);
 
 #ifdef ARB_MPI_ENABLED
@@ -26,7 +28,7 @@ void register_mpi(pybind11::module& m);
 }
 
 PYBIND11_MODULE(arbor, m) {
-    m.doc() = "arbor: Python bindings for Arbor.";
+    m.doc() = "arbor: multicompartment neural network models.";
     m.attr("__version__") = ARB_VERSION;
 
     pyarb::register_cells(m);
@@ -34,6 +36,7 @@ PYBIND11_MODULE(arbor, m) {
     pyarb::register_contexts(m);
     pyarb::register_domain_decomposition(m);
     pyarb::register_event_generators(m);
+    pyarb::register_flat_builder(m);
     pyarb::register_identifiers(m);
     pyarb::register_morphology(m);
     pyarb::register_profiler(m);
@@ -41,8 +44,9 @@ PYBIND11_MODULE(arbor, m) {
     pyarb::register_sampling(m);
     pyarb::register_schedules(m);
     pyarb::register_simulation(m);
+    pyarb::register_single_cell(m);
     pyarb::register_spike_handling(m);
-    
+
     #ifdef ARB_MPI_ENABLED
     pyarb::register_mpi(m);
     #endif
diff --git a/python/recipe.cpp b/python/recipe.cpp
index 35283549326e2ea1d4e16ba02f93fb16d4d8732e..1a4c0f7a602161397ac01ea74ceade9fcd952874 100644
--- a/python/recipe.cpp
+++ b/python/recipe.cpp
@@ -29,18 +29,18 @@ arb::util::unique_any py_recipe_shim::get_cell_description(arb::cell_gid_type gi
                 "Python error already thrown");
 }
 
-arb::probe_info cable_probe(std::string kind, arb::cell_member_type id, arb::mlocation loc) {
-    arb::cell_probe_address::probe_kind pkind;
-    if (kind == "voltage") {
-        pkind = arb::cell_probe_address::probe_kind::membrane_voltage;
+arb::cell_probe_address::probe_kind probe_kind_from_string(const std::string& name) {
+    if (name == "voltage") {
+        return arb::cell_probe_address::probe_kind::membrane_voltage;
     }
-    else if (kind == "current") {
-        pkind = arb::cell_probe_address::probe_kind::membrane_current;
+    else if (name == "current") {
+        return arb::cell_probe_address::probe_kind::membrane_current;
     }
-    else throw pyarb_error(
-                util::pprintf(
-                    "invalid probe kind: {}, neither voltage nor current", kind));
+    else throw pyarb_error(util::pprintf("invalid probe kind: {}, neither voltage nor current", name));
+}
 
+arb::probe_info cable_probe(std::string kind, arb::cell_member_type id, arb::mlocation loc) {
+    auto pkind = probe_kind_from_string(kind);
     arb::cell_probe_address probe{loc, pkind};
     return arb::probe_info{id, pkind, probe};
 };
diff --git a/python/recipe.hpp b/python/recipe.hpp
index ce1ef326f26d2952a64e2ffc67c9679d0b94bd2a..d2e177688d94dbaeb9d2c76acc577447d4ca4ec1 100644
--- a/python/recipe.hpp
+++ b/python/recipe.hpp
@@ -15,6 +15,8 @@
 
 namespace pyarb {
 
+arb::probe_info cable_probe(std::string kind, arb::cell_member_type id, arb::mlocation loc);
+
 // pyarb::recipe is the recipe interface used by Python.
 // Calls that return generic types return pybind11::object, to avoid
 // having to wrap some C++ types used by the C++ interface (specifically
@@ -166,7 +168,7 @@ public:
         return try_catch_pyexception([&](){ return impl_->get_probe(id); }, msg);
     }
 
-    // TODO: wrap and make thread safe
+    // TODO: make thread safe
     arb::util::any get_global_properties(arb::cell_kind kind) const override {
         if (kind==arb::cell_kind::cable) {
             arb::cable_cell_global_properties gprop;
diff --git a/python/s_expr.cpp b/python/s_expr.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..a7e40e8aa8298c1f583882fd86bcd41c45d4fea2
--- /dev/null
+++ b/python/s_expr.cpp
@@ -0,0 +1,404 @@
+#include <iostream>
+
+#include <cctype>
+#include <cstring>
+#include <string>
+#include <memory>
+#include <ostream>
+#include <vector>
+
+#include <arbor/util/either.hpp>
+#include <arbor/arbexcept.hpp>
+
+#include "s_expr.hpp"
+#include "strprintf.hpp"
+
+namespace pyarb {
+
+inline bool is_alphanumeric(char c) {
+    return std::isdigit(c) || std::isalpha(c);
+}
+inline bool is_plusminus(char c) {
+    return (c=='-' || c=='+');
+}
+
+std::ostream& operator<<(std::ostream& o, const tok& t) {
+    switch (t) {
+        case tok::nil:    return o << "nil";
+        case tok::lparen: return o << "lparen";
+        case tok::rparen: return o << "rparen";
+        case tok::real:   return o << "real";
+        case tok::integer:return o << "integer";
+        case tok::name:   return o << "name";
+        case tok::string: return o << "string";
+        case tok::eof:    return o << "eof";
+        case tok::error:  return o << "error";
+    }
+    return o << "<unknown>";
+}
+
+std::ostream& operator<<(std::ostream& o, const token& t) {
+    return o << util::pprintf("{}", t.spelling);
+}
+
+//
+// lexer
+//
+
+struct parser_error: public arb::arbor_exception {
+    int loc;
+    parser_error(const std::string& msg, int l):
+        arbor_exception(msg), loc(l)
+    {}
+};
+
+static std::unordered_map<tok, const char*> tok_to_keyword = {
+    {tok::nil,    "nil"},
+};
+
+static std::unordered_map<std::string, tok> keyword_to_tok = {
+    {"nil",    tok::nil},
+};
+
+class lexer {
+    const char* data_;;
+    const char* end_;;
+    const char* current_;
+    int loc_;
+    token token_;
+
+public:
+
+    lexer(const char* s):
+        data_(s),
+        end_(data_ + std::strlen(data_)),
+        current_(data_)
+    {
+        // Prime the first token.
+        parse();
+    }
+
+    // Return the current token in the stream.
+    const token& current() {
+        return token_;
+    }
+
+    const token& next() {
+        parse();
+        return token_;
+    }
+
+private:
+
+    // Consume the and return the next token in the stream.
+    void parse() {
+        using namespace std::string_literals;
+
+        while (current_!=end_) {
+            loc_ = current_-data_;
+            switch (*current_) {
+                // end of file
+                case 0      :       // end of string
+                    token_ = {loc_, tok::eof, "eof"s};
+                    return;
+
+                // white space
+                case ' '    :
+                case '\t'   :
+                case '\v'   :
+                case '\f'   :
+                case '\n'   :
+                    character();
+                    continue;   // skip to next character
+
+                case '(':
+                    token_ = {loc_, tok::lparen, {character()}};
+                    return;
+                case ')':
+                    token_ = {loc_, tok::rparen, {character()}};
+                    return;
+                case 'a' ... 'z':
+                case 'A' ... 'Z':
+                    token_ = name();
+                    return;
+                case '0' ... '9':
+                    token_ = number();
+                    return;
+                case '"':
+                    token_ = string();
+                    return;
+                case '-':
+                case '+':
+                    {
+                        char c = current_[1];
+                        if (std::isdigit(c) or c=='.') {
+                            token_ = number();
+                            return;
+                        }
+                    }
+                    token_ = {loc_, tok::error,
+                        util::pprintf("Unexpected character '{}'.", character())};
+                    return;
+
+                default:
+                    token_ = {loc_, tok::error,
+                        util::pprintf("Unexpected character '{}'.", character())};
+                    return;
+            }
+        }
+
+        if (current_!=end_) {
+            // todo: handle error: should never hit this
+        }
+        token_ = {loc_, tok::eof, "eof"s};
+        return;
+    }
+
+    // Parse alphanumeric sequence that starts with an alphabet character,
+    // and my contain alphabet, numeric or underscor '_' characters.
+    //
+    // Valid names:
+    //    sub_dendrite
+    //    temp_
+    //    branch3
+    //    A
+    // Invalid names:
+    //    _cat          ; can't start with underscore
+    //    2ndvar        ; can't start with numeric character
+    //
+    // Returns the appropriate token kind if name is a keyword.
+    token name() {
+        std::string name;
+        char c = *current_;
+
+        // Assert that current position is at the start of an identifier
+        if( !(std::isalpha(c)) ) {
+            throw parser_error(
+                "Lexer attempting to read identifier when none is available", loc_);
+        }
+
+        name += c;
+        ++current_;
+        while(1) {
+            c = *current_;
+
+            if(is_alphanumeric(c) || c=='_') {
+                name += character();
+            }
+            else {
+                break;
+            }
+        }
+
+        // test if the name matches a keyword
+        auto it = keyword_to_tok.find(name.c_str());
+        if (it!=keyword_to_tok.end()) {
+            return {loc_, it->second, std::move(name)};
+        }
+        return {loc_, tok::name, std::move(name)};
+    }
+
+    token string() {
+        using namespace std::string_literals;
+
+        ++current_;
+        const char* begin = current_;
+        while (current_!=end_ && character()!='"');
+
+        if (current_==end_) return {loc_, tok::error, "string missing closing \""};
+
+        return {loc_, tok::string, std::string(begin, current_-1)};
+    }
+
+    token number() {
+        using namespace std::string_literals;
+
+        std::string str;
+        char c = *current_;
+
+        // Start counting the number of points in the number.
+        auto num_point = (c=='.' ? 1 : 0);
+        auto uses_scientific_notation = 0;
+
+        str += c;
+        current_++;
+        while(1) {
+            c = *current_;
+            if(std::isdigit(c)) {
+                str += c;
+                current_++;
+            }
+            else if(c=='.') {
+                if (++num_point>1) {
+                    // Can't have more than one '.' in a number
+                    return {int(current_-data_), tok::error, "unexpected '.'"s};
+                }
+                str += c;
+                current_++;
+                if(uses_scientific_notation) {
+                    // Can't have a '.' in the mantissa
+                    return {int(current_-data_), tok::error, "unexpected '.'"s};
+                }
+            }
+            else if(!uses_scientific_notation && (c=='e' || c=='E')) {
+                if(std::isdigit(current_[1]) ||
+                   (is_plusminus(current_[1]) && std::isdigit(current_[2])))
+                {
+                    uses_scientific_notation++;
+                    str += c;
+                    current_++;
+                    // Consume the next char if +/-
+                    if (is_plusminus(*current_)) {
+                        str += *current_++;
+                    }
+                }
+                else {
+                    // the 'e' or 'E' is the beginning of a new token
+                    break;
+                }
+            }
+            else {
+                break;
+            }
+        }
+
+        const bool is_real = uses_scientific_notation || num_point>0;
+        return {loc_, (is_real? tok::real: tok::integer), std::move(str)};
+    }
+
+    char character() {
+        return *current_++;
+    }
+};
+
+bool test_identifier(const char* in) {
+    lexer L(in);
+    auto x = L.current();
+    return x.kind==tok::name && x.spelling==in;
+}
+
+//
+// s expression members
+//
+
+bool s_expr::is_atom() const {
+    return (bool)state;
+}
+
+const token& s_expr::atom() const {
+    return state.get<0>();
+}
+
+const s_expr& s_expr::head() const {
+    return state.get<1>().head.get();
+}
+
+const s_expr& s_expr::tail() const {
+    return state.get<1>().tail.get();
+}
+
+s_expr& s_expr::head() {
+    return state.get<1>().head.get();
+}
+
+s_expr& s_expr::tail() {
+    return state.get<1>().tail.get();
+}
+
+s_expr::operator bool() const {
+    return !(is_atom() && atom().kind==tok::nil);
+}
+
+std::ostream& operator<<(std::ostream& o, const s_expr& x) {
+    if (x.is_atom()) return o << x.atom();
+#if 0 // print full tree with terminating 'nil'
+    return o << "(" << x.head() << " . " << x.tail() << ")";
+#else // print '(a . nil)' as 'a'
+    return x.tail()? o << "(" << x.head() << " . " << x.tail() << ")"
+                   : o << x.head();
+#endif
+}
+
+std::size_t length(const s_expr& l) {
+    if (l.is_atom() && l) {
+        throw arb::arbor_internal_error(
+            util::pprintf("Internal error: can't take length of an atom in '{}'.", l));
+    }
+    if (!l) { // nil
+        return 0u;
+    }
+    return 1+length(l.tail());
+}
+
+int location(const s_expr& l) {
+    if (l.is_atom()) return l.atom().column;
+    return location(l.head());
+}
+
+//
+// parsing s expressions
+//
+
+// If there is a parsing error, then an atom with kind==tok::error is returned
+// with the error string in its spelling.
+s_expr parse(lexer& L) {
+    using namespace std::string_literals;
+
+    s_expr node;
+    auto t = L.current();
+
+    if (t.kind==tok::lparen) {
+        t = L.next();
+        s_expr* n = &node;
+        while (true) {
+            if (t.kind == tok::eof) {
+                return token{t.column, tok::error,
+                    "Unexpected end of input. Missing a closing parenthesis ')'."};;
+            }
+            if (t.kind == tok::error) {
+                return t;
+            }
+            else if (t.kind == tok::rparen) {
+                *n = token{t.column, tok::nil, "nil"};
+                break;
+            }
+            else if (t.kind == tok::lparen) {
+                auto e = parse(L);
+                if (e.is_atom() && e.atom().kind==tok::error) return e;
+                *n = {std::move(e), {}};
+            }
+            else {
+                *n = {s_expr(t), {}};
+            }
+
+            n = &n->tail();
+            t = L.next();
+        }
+    }
+    else {
+        return token{t.column, tok::error, "Missing opening parenthesis'('."};;
+    }
+
+    return node;
+}
+
+s_expr parse(const char* in) {
+    lexer l(in);
+    s_expr result = parse(l);
+    const bool err = result.is_atom()? result.atom().kind==tok::error: false;
+    if (!err) {
+        auto t = l.next(); // pop the last rparen token.
+        if (t.kind!=tok::eof) {
+            return token{t.column, tok::error,
+                         util::pprintf("Unexpected '{}' at the end of input.", t)};
+        }
+    }
+    return result;
+}
+
+s_expr parse(const std::string& in) {
+    return parse(in.c_str());
+}
+
+} // namespace pyarb
+
diff --git a/python/s_expr.hpp b/python/s_expr.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..f24e5f7cdd1f8b3fadb93bcc008356d69e3220fa
--- /dev/null
+++ b/python/s_expr.hpp
@@ -0,0 +1,125 @@
+#pragma once
+
+#include <string>
+#include <memory>
+#include <vector>
+
+#include <arbor/util/either.hpp>
+#include <arbor/util/optional.hpp>
+
+namespace pyarb {
+
+enum class tok {
+    nil,
+    real,       // real number
+    integer,    // integer
+    name,       // name
+    lparen,     // left parenthesis '('
+    rparen,     // right parenthesis ')'
+    string,     // string, written as "spelling"
+    eof,        // end of file/input
+    error       // special error state marker
+};
+
+std::ostream& operator<<(std::ostream&, const tok&);
+
+struct token {
+    int column;
+    tok kind;
+    std::string spelling;
+};
+
+std::ostream& operator<<(std::ostream&, const token&);
+
+std::vector<token> tokenize(const char* line);
+
+struct s_expr {
+    template <typename U>
+    struct s_pair {
+        U head = U();
+        U tail = U();
+        s_pair(U l, U r): head(std::move(l)), tail(std::move(r)) {}
+    };
+
+    // This value_wrapper is used to wrap the shared pointer
+    template <typename T>
+    struct value_wrapper{
+        using state_t = std::unique_ptr<T>;
+        state_t state;
+
+        value_wrapper() = default;
+
+        value_wrapper(const T& v):
+            state(std::make_unique<T>(v)) {}
+
+        value_wrapper(T&& v):
+            state(std::make_unique<T>(std::move(v))) {}
+
+        value_wrapper(const value_wrapper& other):
+            state(std::make_unique<T>(other.get())) {}
+
+        value_wrapper& operator=(const value_wrapper& other) {
+            state = std::make_unique<T>(other.get());
+            return *this;
+        }
+
+        value_wrapper(value_wrapper&& other) = default;
+
+        friend std::ostream& operator<<(std::ostream& o, const value_wrapper& w) {
+            return o << *w.state;
+        }
+
+        operator T() const {
+            return *state;
+        }
+
+        const T& get() const {
+            return *state;
+        }
+
+        T& get() {
+            return *state;
+        }
+    };
+
+    // An s_expr can be one of
+    //      1. an atom
+    //      2. a pair of s_expr (head and tail)
+    // The s_expr uses a util::either to represent these two possible states,
+    // which requires using an incomplete definition of s_expr, requiring
+    // with a std::shared_ptr.
+
+    using pair_type = s_pair<value_wrapper<s_expr>>;
+    arb::util::either<token, pair_type> state;
+
+    s_expr(const s_expr& s): state(s.state) {}
+    s_expr() = default;
+    s_expr(token t): state(std::move(t)) {}
+    s_expr(s_expr l, s_expr r):
+        state(pair_type(std::move(l), std::move(r)))
+    {}
+
+    bool is_atom() const;
+
+    const token& atom() const;
+
+    operator bool() const;
+
+    const s_expr& head() const;
+    const s_expr& tail() const;
+    s_expr& head();
+    s_expr& tail();
+
+    friend std::ostream& operator<<(std::ostream& o, const s_expr& x);
+};
+
+std::size_t length(const s_expr& l);
+int location(const s_expr& l);
+
+s_expr parse(const char* line);
+s_expr parse(const std::string& line);
+
+bool test_identifier(const char* in);
+
+} // namespace pyarb
+
diff --git a/python/single_cell_model.cpp b/python/single_cell_model.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..6d5084c47c558ee5e266eaeb9ad269dd0a408c51
--- /dev/null
+++ b/python/single_cell_model.cpp
@@ -0,0 +1,266 @@
+#include <string>
+#include <vector>
+
+#include <pybind11/pybind11.h>
+#include <pybind11/stl.h>
+
+#include <arbor/cable_cell.hpp>
+#include <arbor/load_balance.hpp>
+#include <arbor/recipe.hpp>
+#include <arbor/simulation.hpp>
+
+#include "error.hpp"
+
+namespace pyarb {
+
+//
+// Implementation of sampling infrastructure for Python single cell models.
+//
+// Note that only voltage sampling is currently supported, which simplifies the
+// code somewhat.
+//
+
+// Stores the location and sampling frequency for a probe in a single cell model.
+struct probe_site {
+    arb::mlocation site;  // Location of sample on morphology.
+    double frequency;     // Sampling frequency [Hz].
+};
+
+// Stores a single trace, which can be queried and viewed by the user at the end
+// of a simulation run.
+struct trace {
+    std::string variable;           // Name of the variable being recorded.
+    arb::mlocation loc;             // Sample site on morphology.
+    std::vector<arb::time_type> t;  // Sample times [ms].
+    std::vector<double> v;          // Sample values [units specific to sample variable].
+};
+
+// Callback provided to sampling API that records into a trace variable.
+struct trace_callback {
+    trace& trace_;
+
+    trace_callback(trace& t): trace_(t) {}
+
+    void operator()(arb::cell_member_type probe_id, arb::probe_tag tag, std::size_t n, const arb::sample_record* recs) {
+        // Push each (time, value) pair from the last epoch into trace_.
+        for (std::size_t i=0; i<n; ++i) {
+            if (auto p = arb::util::any_cast<const double*>(recs[i].data)) {
+                trace_.t.push_back(recs[i].time);
+                trace_.v.push_back(*p);
+            }
+            else {
+                throw std::runtime_error("unexpected sample type in simple_sampler");
+            }
+        }
+    }
+};
+
+// Used internally by the single cell model to expose model information to the
+// arb::simulation API when a model is instantiated.
+// Model descriptors, i.e. the cable_cell and probes, are instantiated
+// in the single_cell_model by user calls. The recipe is generated lazily, just
+// before simulation construction, so the recipe can use const references to all
+// of the model descriptors.
+struct single_cell_recipe: arb::recipe {
+    const arb::cable_cell& cell_;
+    const std::vector<probe_site>& probes_;
+    const arb::cable_cell_global_properties& gprop_;
+
+    single_cell_recipe(
+            const arb::cable_cell& c,
+            const std::vector<probe_site>& probes,
+            const arb::cable_cell_global_properties& props):
+        cell_(c), probes_(probes), gprop_(props)
+    {}
+
+    virtual arb::cell_size_type num_cells() const override {
+        return 1;
+    }
+
+    virtual arb::util::unique_any get_cell_description(arb::cell_gid_type gid) const override {
+        return cell_;
+    }
+
+    virtual arb::cell_kind get_cell_kind(arb::cell_gid_type) const override {
+        return arb::cell_kind::cable;
+    }
+
+    virtual arb::cell_size_type num_sources(arb::cell_gid_type) const override {
+        return cell_.detectors().size();
+    }
+
+    // synapses, connections and event generators
+
+    virtual arb::cell_size_type num_targets(arb::cell_gid_type) const override {
+        return cell_.synapses().size();
+    }
+
+    virtual std::vector<arb::cell_connection> connections_on(arb::cell_gid_type) const override {
+        return {}; // no connections on a single cell model
+    }
+
+    virtual std::vector<arb::event_generator> event_generators(arb::cell_gid_type) const override {
+        return {};
+    }
+
+    // probes
+
+    virtual arb::cell_size_type num_probes(arb::cell_gid_type)  const override {
+        return probes_.size();
+    }
+
+    virtual arb::probe_info get_probe(arb::cell_member_type probe_id) const override {
+        // Test that a valid probe site is requested.
+        if (probe_id.gid || probe_id.index>=probes_.size()) {
+            throw arb::bad_probe_id(probe_id);
+        }
+
+        // For now only voltage can be selected for measurement.
+        auto kind = arb::cell_probe_address::membrane_voltage;
+        const auto& loc = probes_[probe_id.index].site;
+        return arb::probe_info{probe_id, kind, arb::cell_probe_address{loc, kind}};
+    }
+
+    // gap junctions
+
+    virtual arb::cell_size_type num_gap_junction_sites(arb::cell_gid_type gid)  const override {
+        return 0; // No gap junctions on a single cell model.
+    }
+
+    virtual std::vector<arb::gap_junction_connection> gap_junctions_on(arb::cell_gid_type) const override {
+        return {}; // No gap junctions on a single cell model.
+    }
+
+    virtual arb::util::any get_global_properties(arb::cell_kind) const override {
+        return gprop_;
+    }
+};
+
+class single_cell_model {
+    arb::cable_cell cell_;
+    arb::context ctx_;
+    bool run_ = false;
+    arb::cable_cell_global_properties gprop_;
+
+    std::vector<probe_site> probes_;
+    std::unique_ptr<arb::simulation> sim_;
+    std::vector<double> spike_times_;
+    // Create one trace for each probe.
+    std::vector<trace> traces_;
+
+public:
+    single_cell_model(arb::cable_cell c):
+        cell_(std::move(c)), ctx_(arb::make_context())
+    {
+        gprop_.default_parameters = arb::neuron_parameter_defaults;
+    }
+
+    // example use:
+    //      m.probe('voltage', arbor.location(2,0.5))
+    void probe(const std::string& what, const arb::mlocation& where, double frequency) {
+        if (what != "voltage") {
+            throw pyarb_error(
+                util::pprintf("{} does not name a valid variable to trace (currently only 'voltage' is supported)", what));
+        }
+        if (frequency<=0) {
+            throw pyarb_error(
+                util::pprintf("sampling frequency is not greater than zero", what));
+        }
+        if (where.branch>=cell_.morphology().num_branches()) {
+            throw pyarb_error(
+                util::pprintf("invalid location", what));
+        }
+        probes_.push_back({where, frequency});
+    }
+
+    void add_ion(const std::string& ion, double valence, double int_con, double ext_con, double rev_pot) {
+        gprop_.add_ion(ion, valence, int_con, ext_con, rev_pot);
+    }
+
+    void run(double tfinal) {
+        single_cell_recipe rec(cell_, probes_, gprop_);
+
+        auto domdec = arb::partition_load_balance(rec, ctx_);
+
+        sim_ = std::make_unique<arb::simulation>(rec, domdec, ctx_);
+
+        // Create one trace for each probe.
+        traces_.reserve(probes_.size());
+
+        // Add probes
+        for (arb::cell_lid_type i=0; i<probes_.size(); ++i) {
+            const auto& p = probes_[i];
+
+            traces_.push_back({"voltage", p.site, {}, {}});
+
+            auto sched = arb::regular_schedule(1000./p.frequency);
+
+            // Now attach the sampler at probe site, with sampling schedule sched, writing to voltage
+            sim_->add_sampler(arb::one_probe({0,i}), sched, trace_callback(traces_[i]));
+        }
+
+        // Set callback that records spike times.
+        sim_->set_global_spike_callback(
+            [this](const std::vector<arb::spike>& spikes) {
+                for (auto& s: spikes) {
+                    spike_times_.push_back(s.time);
+                }
+            });
+
+        sim_->run(tfinal, 0.025);
+
+        run_ = true;
+    }
+
+    const std::vector<double>& spike_times() const {
+        return spike_times_;
+    }
+
+    const std::vector<trace>& traces() const {
+        return traces_;
+    }
+};
+
+void register_single_cell(pybind11::module& m) {
+    using namespace pybind11::literals;
+
+    pybind11::class_<trace> tr(m, "trace", "Values and meta-data for a sample-trace on a single cell model.");
+    tr
+        .def_readonly("variable", &trace::variable, "Name of the variable being recorded.")
+        .def_readonly("location", &trace::loc, "Location on cell morphology.")
+        .def_readonly("time",    &trace::t, "Time stamps of samples [ms].")
+        .def_readonly("value",   &trace::v, "Sample values.");
+
+    pybind11::class_<single_cell_model> model(m, "single_cell_model",
+        "Wrapper for simplified description, and execution, of single cell models.");
+
+    model
+        .def(pybind11::init<arb::cable_cell>(),
+            "cell"_a, "Initialise a single cell model for a cable cell.")
+        .def("run", &single_cell_model::run, "tfinal"_a, "Run model from t=0 to t=tfinal ms.")
+        .def("probe", &single_cell_model::probe,
+            "what"_a, "where"_a, "frequency"_a,
+            "Sample a variable on the cell.\n"
+            " what:      Name of the variable to record (currently only 'voltage').\n"
+            " where:     Location on cell morphology at which to sample the variable.\n"
+            " frequency: The target frequency at which to sample [Hz].")
+        .def("add_ion", &single_cell_model::add_ion,
+            "ion"_a, "valence"_a, "int_con"_a, "ext_con"_a, "rev_pot"_a,
+            "Add a new ion species to the model.\n"
+            " ion: name of the ion species.\n"
+            " valence: valence of the ion species.\n"
+            " int_con: initial internal concentration [mM].\n"
+            " ext_con: initial external concentration [mM].\n"
+            " rev_pot: reversal potential [mV].")
+        .def_property_readonly("spikes",
+            [](const single_cell_model& m) {
+                return m.spike_times();}, "Holds spike times [ms] after a call to run().")
+        .def_property_readonly("traces",
+            [](const single_cell_model& m) {
+                return m.traces();}, "Holds sample traces after a call to run().")
+        .def("__repr__", [](const single_cell_model&){return "<arbor.single_cell_model>";})
+        .def("__str__",  [](const single_cell_model&){return "<arbor.single_cell_model>";});
+}
+
+} // namespace pyarb
+
diff --git a/python/strprintf.hpp b/python/strprintf.hpp
index e3b9dd49914318627f96c7f07c301df7f9b3fbbf..e8f96100fbfff0f74320eec7dc5eb751bdb645c1 100644
--- a/python/strprintf.hpp
+++ b/python/strprintf.hpp
@@ -7,6 +7,7 @@
 #include <string>
 #include <sstream>
 #include <system_error>
+#include <unordered_map>
 #include <utility>
 #include <vector>
 
@@ -132,6 +133,25 @@ namespace impl {
         }
     };
 
+    template <typename Seq, typename F>
+    struct sepval_transform {
+        const Seq& seq_;
+        const char* sep_;
+        const F f_;
+
+        sepval_transform(const Seq& seq, const char* sep, F&& f): seq_(seq), sep_(sep), f_(std::forward(f)) {}
+
+        friend std::ostream& operator<<(std::ostream& o, const sepval_transform& s) {
+            bool first = true;
+            for (auto& x: s.seq_) {
+                if (!first) o << s.sep_;
+                first = false;
+                o << s.f(x);
+            }
+            return o;
+        }
+    };
+
     template <typename Seq>
     struct sepval_lim {
         const Seq& seq_;
@@ -179,6 +199,21 @@ impl::sepval_lim<Seq> csv(const Seq& seq, unsigned n) {
     return impl::sepval_lim<Seq>(seq, ", ", n);
 }
 
+// Print dictionary: this could be done easily with range adaptors in C++17
+template <typename Key, typename T>
+std::string dictionary_csv(const std::unordered_map<Key, T>& dict) {
+    constexpr bool string_key = std::is_same<std::string, std::decay_t<Key>>::value;
+    std::string s = "{";
+    bool first = true;
+    for (auto& p: dict) {
+        if (!first) s += ", ";
+        first = false;
+        s += pprintf(string_key? "'{}': {}": "{}: {}", p.first, p.second);
+    }
+    s += "}";
+    return s;
+}
+
 } // namespace util
 
 template <typename T>
diff --git a/python/test/CMakeLists.txt b/python/test/CMakeLists.txt
new file mode 100644
index 0000000000000000000000000000000000000000..1af9808f3b20e1f48d6652276c40a283c798ae3d
--- /dev/null
+++ b/python/test/CMakeLists.txt
@@ -0,0 +1,3 @@
+# Unit tests.
+# Builds: unit.
+add_subdirectory(cpp)
diff --git a/python/test/cpp/CMakeLists.txt b/python/test/cpp/CMakeLists.txt
new file mode 100644
index 0000000000000000000000000000000000000000..291da50100446b139640fa0a2d6fe310878bba8e
--- /dev/null
+++ b/python/test/cpp/CMakeLists.txt
@@ -0,0 +1,27 @@
+set(py_unit_sources
+    s_expr.cpp
+
+    # unit test driver
+    test.cpp
+)
+
+add_executable(py_unit EXCLUDE_FROM_ALL ${py_unit_sources})
+add_dependencies(tests py_unit)
+
+add_library(py_unit_lib $<TARGET_OBJECTS:pyarb_obj>)
+target_link_libraries(py_unit_lib PRIVATE arbor pybind11::module)
+
+target_compile_options(py_unit PRIVATE ${ARB_CXXOPT_ARCH})
+target_include_directories(
+    py_unit PRIVATE
+    "${CMAKE_CURRENT_BINARY_DIR}"
+    "${PROJECT_SOURCE_DIR}/python"
+)
+
+target_link_libraries(
+    py_unit PRIVATE
+    gtest
+    arbor
+    py_unit_lib
+    pybind11::module
+)
diff --git a/python/test/cpp/s_expr.cpp b/python/test/cpp/s_expr.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..567ec754168b0c4eb07fc1816cbe35d998cf2aa4
--- /dev/null
+++ b/python/test/cpp/s_expr.cpp
@@ -0,0 +1,86 @@
+#include "gtest.h"
+
+#include <arbor/morph/region.hpp>
+#include <arbor/morph/locset.hpp>
+
+#include <morph_parse.hpp>
+#include <s_expr.hpp>
+
+using namespace pyarb;
+using namespace std::string_literals;
+
+TEST(s_expr, identifier) {
+    EXPECT_TRUE(test_identifier("foo"));
+    EXPECT_TRUE(test_identifier("f1"));
+    EXPECT_TRUE(test_identifier("f_"));
+    EXPECT_TRUE(test_identifier("f_1__"));
+    EXPECT_TRUE(test_identifier("A_1__"));
+
+    EXPECT_FALSE(test_identifier("_foobar"));
+    EXPECT_FALSE(test_identifier("2dogs"));
+    EXPECT_FALSE(test_identifier("1"));
+    EXPECT_FALSE(test_identifier("_"));
+    EXPECT_FALSE(test_identifier(""));
+    EXPECT_FALSE(test_identifier(" foo"));
+    EXPECT_FALSE(test_identifier("foo "));
+    EXPECT_FALSE(test_identifier("foo bar"));
+    EXPECT_FALSE(test_identifier("foo-bar"));
+    EXPECT_FALSE(test_identifier(""));
+}
+
+TEST(s_expr, atoms) {
+    auto get_atom = [](s_expr e) {
+        EXPECT_EQ(1u, length(e));
+        EXPECT_TRUE(e.head().is_atom());
+        return e.head().atom();
+    };
+
+    for (auto spelling: {"foo", "bar_", "car1", "car_1", "x_1__"}){
+        auto a = get_atom(parse("("s+spelling+")"));
+        EXPECT_EQ(a.kind, tok::name);
+        EXPECT_EQ(a.spelling, spelling);
+    }
+    // test parsing of integers
+    for (auto spelling: {"0", "-0", "1", "42", "-3287"}){
+        auto a = get_atom(parse("("s+spelling+")"));
+        EXPECT_EQ(a.kind, tok::integer);
+        EXPECT_EQ(a.spelling, spelling);
+    }
+    // test parsing of real numbers
+    for (auto spelling: {"0.", "-0.0", "1.21", "42.", "-3287.12"}){
+        auto a = get_atom(parse("("s+spelling+")"));
+        EXPECT_EQ(a.kind, tok::real);
+        EXPECT_EQ(a.spelling, spelling);
+    }
+    // test parsing of strings
+    for (auto spelling: {"foo", "dog cat", ""}) {
+        auto s = "(\""s+spelling+"\")";
+        auto a = get_atom(parse(s));
+        EXPECT_EQ(a.kind, tok::string);
+    }
+}
+
+TEST(s_expr, parse) {
+    auto round_trip_reg = [](const char* in) {
+        auto x = eval(parse(in));
+        return util::pprintf("{}", arb::util::any_cast<arb::region>(*x));
+    };
+    auto round_trip_loc = [](const char* in) {
+        auto x = eval(parse(in));
+        return util::pprintf("{}", arb::util::any_cast<arb::locset>(*x));
+    };
+
+    EXPECT_EQ("(cable 3 0 1)",      round_trip_reg("(branch 3)"));
+    EXPECT_EQ("(cable 2 0.1 0.4)",  round_trip_reg("(cable 2 0.1 0.4)"));
+    EXPECT_EQ("(all)",              round_trip_reg("(all)"));
+    EXPECT_EQ("(region \"foo\")",   round_trip_reg("(region \"foo\")"));
+
+    EXPECT_EQ("(terminal)", round_trip_loc("(terminal)"));
+    EXPECT_EQ("(root)",     round_trip_loc("(root)"));
+    EXPECT_EQ("(locset \"cat_burgler\")", round_trip_loc("(locset \"cat_burgler\")"));
+
+    auto lhs = arb::util::any_cast<arb::region>(*eval(parse("(region \"dend\")")));
+    auto rhs = arb::util::any_cast<arb::region>(*eval(parse("(all)")));
+
+    EXPECT_EQ(util::pprintf("{}", join(lhs,rhs)), "(join (region \"dend\") (all))");
+}
diff --git a/python/test/cpp/test.cpp b/python/test/cpp/test.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..f2eab0931a0b4b2a5b17ed12ba6c2f3d1d09d27c
--- /dev/null
+++ b/python/test/cpp/test.cpp
@@ -0,0 +1,11 @@
+#include <iostream>
+#include <fstream>
+#include <numeric>
+#include <vector>
+
+#include "gtest.h"
+
+int main(int argc, char **argv) {
+    ::testing::InitGoogleTest(&argc, argv);
+    return RUN_ALL_TESTS();
+}
diff --git a/scripts/travis/build.sh b/scripts/travis/build.sh
index bacba3110db1945bbbb64ce1dfba67e13e4f926a..000e5e11662ddb9914b35408d43091f38f82e8b4 100755
--- a/scripts/travis/build.sh
+++ b/scripts/travis/build.sh
@@ -20,6 +20,8 @@ echo "compiler   : ${compiler_version}"
 echo "cmake      : ${cmake_version}"
 echo "build path : ${build_path}"
 echo "base path  : ${base_path}"
+echo "python3    : $(which python3)"
+echo "python3ver : $(python3 --version)"
 
 if [[ "${WITH_DISTRIBUTED}" == "mpi" ]]; then
     echo "mpi        : on"
@@ -50,6 +52,13 @@ if [[ "${WITH_PYTHON}" == "true" ]]; then
     python_path=$base_path/python
     echo "python src : ${python_path}"
     echo "PYTHONPATH : ${PYTHONPATH}"
+
+    if [[ "$TRAVIS_OS_NAME" = "linux" ]]; then
+        pypref=$(python-config --prefix)
+        PY_FLAGS="-DPYTHON_EXECUTABLE=$pypref/bin/python3.6 -DPYTHON_INCLUDE_DIR=$pypref/include/python3.6m -DPYTHON_LIBRARY=$pypref/lib/python3.6/config-3.6m-x86_64-linux-gnu/libpython3.6m.so"
+    else
+        PY_FLAGS=""
+    fi
 else
     echo "python     : off"
     ARB_WITH_PYTHON="OFF"
@@ -66,11 +75,11 @@ cd $build_path
 #
 progress "Configuring with cmake"
 
-cmake_flags="-DARB_WITH_ASSERTIONS=ON -DARB_WITH_MPI=${WITH_MPI} -DARB_WITH_PYTHON=${ARB_WITH_PYTHON} ${CXX_FLAGS}"
+cmake_flags="-DARB_WITH_ASSERTIONS=ON -DARB_WITH_MPI=${WITH_MPI} -DARB_WITH_PYTHON=${ARB_WITH_PYTHON} ${CXX_FLAGS} ${PY_FLAGS}"
 echo "cmake flags: ${cmake_flags}"
 cmake .. ${cmake_flags} || error "unable to configure cmake"
 
-export NMC_NUM_THREADS=2
+export ARB_NUM_THREADS=2
 
 progress "C++ unit tests"
 make unit -j4                || error "building unit tests"
@@ -96,8 +105,12 @@ if [[ "${WITH_PYTHON}" == "true" ]]; then
     progress "Python unit tests"
     python$PY $python_path/test/unit/runner.py -v2                           || error "running python unit tests (serial)"
     if [[ "${WITH_DISTRIBUTED}" = "mpi" ]]; then
-        progress "Python distributed unit tests (MPI)"
-        ${launch} python$PY $python_path/test/unit_distributed/runner.py -v2 || error "running python distributed unit tests (MPI)"
+        if [[ "$TRAVIS_OS_NAME" = "osx" ]]; then
+            progress "Python distributed unit tests (MPI)"
+            ${launch} python$PY $python_path/test/unit_distributed/runner.py -v2 || error "running python distributed unit tests (MPI)"
+        else
+            progress "Python distributed unit tests (MPI) -- skipping on Linux due to Travis config issues on Bionic."
+        fi
     fi
 fi