diff --git a/CMakeLists.txt b/CMakeLists.txt
index 0ffce50ffc32f9e136adc25ef1fb4309667eab04..c89d2d979e30142e96da464c9d9d46ec9aec9f2e 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -25,15 +25,6 @@ option(ARB_VECTORIZE "use explicit SIMD code in generated mechanisms" OFF)
 
 set(ARB_MODCC "" CACHE STRING "path to external modcc NMODL compiler")
 
-# Generate validation data for validation tests?
-
-option(ARB_BUILD_VALIDATION_DATA "generate validation data" OFF)
-
-# Where to generate and find validation data?
-
-set(ARB_VALIDATION_DATA_DIR "${PROJECT_SOURCE_DIR}/validation/data" CACHE PATH
-  "location of generated validation data")
-
 # For sup::glop, just wrap POSIX glob(3)?
 # Turn off for platforms without glob(3) in libc, e.g. Android Bionic.
 
@@ -372,7 +363,7 @@ add_subdirectory(arbor)
 # arborenv, arborenv-public-headers:
 add_subdirectory(arborenv)
 
-# unit, unit-mpi, unit-local, unit-modcc, validate
+# unit, unit-mpi, unit-local, unit-modcc
 add_subdirectory(test)
 
 # self contained examples:
@@ -381,11 +372,6 @@ add_subdirectory(example)
 # html:
 add_subdirectory(doc)
 
-# validation-data:
-if(ARB_BUILD_VALIDATION_DATA)
-    add_subdirectory(validation)
-endif()
-
 # python interface:
 if (ARB_WITH_PYTHON)
     add_subdirectory(python)
diff --git a/arbor/cable_cell.cpp b/arbor/cable_cell.cpp
index affac000ba94f30a01317cbd23a21eaecd3c661d..bf41a4b53111ad44ebd3b2a482de6aea3d8b9f3e 100644
--- a/arbor/cable_cell.cpp
+++ b/arbor/cable_cell.cpp
@@ -30,11 +30,85 @@ struct cable_cell_impl {
     using region_map = cable_cell::region_map;
     using locset_map = cable_cell::locset_map;
 
-    cable_cell_impl() {
-        segments.push_back(make_segment<placeholder_segment>());
-        parents.push_back(0);
+    cable_cell_impl(const arb::morphology& m,
+                    const label_dict& dictionary,
+                    bool compartments_from_discretization):
+        morph(m)
+    {
+        using point = cable_cell::point_type;
+        if (!m.num_branches()) {
+            segments.push_back(make_segment<placeholder_segment>());
+            parents.push_back(0);
+            return;
+        }
+
+        // Add the soma.
+        auto loc = m.samples()[0].loc; // location of soma.
+
+        // If there is no spherical root/soma use a zero-radius soma.
+        double srad = m.spherical_root()? loc.radius: 0.;
+        segments.push_back(make_segment<soma_segment>(srad, point(loc.x, loc.y, loc.z)));
+        parents.push_back(-1);
+
+        auto& samples = m.samples();
+        auto& props = m.sample_props();
+        for (auto i: util::make_span(1, m.num_branches())) {
+            auto index =  util::make_range(m.branch_indexes(i));
+
+            // find kind for the branch. Use the tag of the last sample in the branch.
+            int tag = samples[index.back()].tag;
+            section_kind kind;
+            switch (tag) {
+                case 1:     // soma
+                    throw cable_cell_error("No support for complex somata (yet)");
+                case 2:     // axon
+                    kind = section_kind::axon;
+                case 3:     // dendrite
+                case 4:     // apical dendrite
+                default:    // just take dendrite as default
+                    kind = section_kind::dendrite;
+            }
+
+            std::vector<value_type> radii;
+            std::vector<cable_cell::point_type> points;
+
+            // The current discretization code does not handle collocated points correctly,
+            // particularly if they lie at the start of a branch, so we have to skip the first
+            // point on a branch if it is collocated with the second point.
+            bool skip_first = is_collocated(props[index[1]]);
+            for (auto j: util::make_span(skip_first, index.size())) {
+                auto& s = samples[index[j]];
+                radii.push_back(s.loc.radius);
+                points.push_back(cable_cell::point_type(s.loc.x, s.loc.y, s.loc.z));
+            }
+
+            // Find the id of this branch's parent.
+            auto pid = m.branch_parent(i);
+            // Adjust pid if a zero-radius soma was used.
+            if (!m.spherical_root()) {
+                pid = pid==mnpos? 0: pid+1;
+            }
+            segments.push_back(make_segment<cable_segment>(kind, radii, points));
+            parents.push_back(pid);
+            if (compartments_from_discretization) {
+                int ncolloc = std::count_if(index.begin(), index.end(), [&props](auto i){return is_collocated(props[i]);});
+                int ncomp = index.size()-ncolloc-1;
+                ncomp -= is_collocated(props[index[0]]);
+                segments.back()->as_cable()->set_compartments(ncomp);
+            }
+        }
+
+        for (auto& r: dictionary.regions()) {
+            regions[r.first] = thingify(r.second, morph);
+        }
+
+        for (auto& l: dictionary.locsets()) {
+            locations[l.first] = thingify(l.second, morph);
+        }
     }
 
+    cable_cell_impl(): cable_cell_impl({},{},false) {}
+
     cable_cell_impl(const cable_cell_impl& other) {
         parents = other.parents;
         stimuli = other.stimuli;
@@ -93,6 +167,16 @@ struct cable_cell_impl {
         return lid_range(first, list.size());
     }
 
+    template <typename Desc, typename T>
+    lid_range place(const std::string& target, const Desc& desc, std::vector<T>& list) {
+        const auto first = list.size();
+
+        const auto it = locations.find(target);
+        if (it==locations.end()) return lid_range(first, first);
+
+        return place(it->second, desc, list);
+    }
+
     lid_range place_gj(const mlocation_list& locs) {
         const auto first = gap_junction_sites.size();
 
@@ -101,6 +185,15 @@ struct cable_cell_impl {
         return lid_range(first, gap_junction_sites.size());
     }
 
+    lid_range place_gj(const std::string& target) {
+        const auto first = gap_junction_sites.size();
+
+        const auto it = locations.find(target);
+        if (it==locations.end()) return lid_range(first, first);
+
+        return place_gj(it->second);
+    }
+
     void assert_valid_segment(index_type i) const {
         if (i>=segments.size()) {
             throw cable_cell_error("no such segment");
@@ -110,6 +203,28 @@ struct cable_cell_impl {
     bool valid_location(const mlocation& loc) const {
         return test_invariants(loc) && loc.branch<segments.size();
     }
+
+    template <typename F>
+    void paint(const std::string& target, F&& f) {
+        auto it = regions.find(target);
+
+        // Nothing to do if there are no regions that match.
+        if (it==regions.end()) return;
+
+        paint(it->second, std::forward<F>(f));
+    }
+
+    template <typename F>
+    void paint(const mcable_list& cables, F&& f) {
+        for (auto c: cables) {
+            if (c.prox_pos!=0 || c.dist_pos!=1) {
+                throw cable_cell_error(util::pprintf(
+                    "cable_cell does not support regions with partial branches: {}", c));
+            }
+            assert_valid_segment(c.branch);
+            f(segments[c.branch]);
+        }
+    }
 };
 
 using impl_ptr = std::unique_ptr<cable_cell_impl, void (*)(cable_cell_impl*)>;
@@ -117,6 +232,12 @@ impl_ptr make_impl(cable_cell_impl* c) {
     return impl_ptr(c, [](cable_cell_impl* p){delete p;});
 }
 
+cable_cell::cable_cell(const arb::morphology& m,
+                       const label_dict& dictionary,
+                       bool compartments_from_discretization):
+    impl_(make_impl(new cable_cell_impl(m, dictionary, compartments_from_discretization)))
+{}
+
 cable_cell::cable_cell():
     impl_(make_impl(new cable_cell_impl()))
 {}
@@ -126,42 +247,10 @@ cable_cell::cable_cell(const cable_cell& other):
     impl_(make_impl(new cable_cell_impl(*other.impl_)))
 {}
 
-size_type cable_cell::num_segments() const {
+size_type cable_cell::num_branches() const {
     return impl_->segments.size();
 }
 
-//
-// note: I think that we have to enforce that the soma is the first
-//       segment that is added
-//
-soma_segment* cable_cell::add_soma(value_type radius, point_type center) {
-    if (has_soma()) {
-        throw cable_cell_error("cell already has soma");
-    }
-    impl_->segments[0] = make_segment<soma_segment>(radius, center);
-    return impl_->segments[0]->as_soma();
-}
-
-cable_segment* cable_cell::add_cable(index_type parent, segment_ptr&& cable) {
-    if (!cable->as_cable()) {
-        throw cable_cell_error("segment is not a cable segment");
-    }
-
-    if (parent>num_segments()) {
-        throw cable_cell_error("parent index out of range");
-    }
-
-    impl_->segments.push_back(std::move(cable));
-    impl_->parents.push_back(parent);
-
-    return impl_->segments.back()->as_cable();
-}
-
-segment* cable_cell::segment(index_type index) {
-    impl_->assert_valid_segment(index);
-    return impl_->segments[index].get();
-}
-
 segment const* cable_cell::parent(index_type index) const {
     impl_->assert_valid_segment(index);
     return impl_->segments[impl_->parents[index]].get();
@@ -206,12 +295,8 @@ const std::vector<cable_cell::stimulus_instance>& cable_cell::stimuli() const {
     return impl_->stimuli;
 }
 
-void cable_cell::set_regions(cable_cell::region_map r) {
-    impl_->regions = std::move(r);
-}
-
-void cable_cell::set_locsets(cable_cell::locset_map l) {
-    impl_->locations = std::move(l);
+const em_morphology* cable_cell::morphology() const {
+    return &(impl_->morph);
 }
 
 //
@@ -221,19 +306,23 @@ void cable_cell::set_locsets(cable_cell::locset_map l) {
 //
 
 void cable_cell::paint(const std::string& target, mechanism_desc desc) {
-    auto it = impl_->regions.find(target);
+    impl_->paint(target,
+                 [&desc](segment_ptr& s){return s->add_mechanism(desc);});
+}
 
-    // Nothing to do if there are no regions that match.
-    if (it==impl_->regions.end()) return;
+void cable_cell::paint(const std::string& target, cable_cell_local_parameter_set params) {
+    impl_->paint(target,
+                 [&params](segment_ptr& s){return s->parameters = params;});
+}
 
-    for (auto c: it->second) {
-        if (c.prox_pos!=0 || c.dist_pos!=1) {
-            throw cable_cell_error(util::pprintf(
-                "cable_cell does not support regions with partial branches: \"{}\": {}",
-                target, c));
-        }
-        segment(c.branch)->add_mechanism(desc);
-    }
+void cable_cell::paint(const region& target, mechanism_desc desc) {
+    impl_->paint(thingify(target, impl_->morph),
+                 [&desc](segment_ptr& s){return s->add_mechanism(desc);});
+}
+
+void cable_cell::paint(const region& target, cable_cell_local_parameter_set params) {
+    impl_->paint(thingify(target, impl_->morph),
+                 [&params](segment_ptr& s){return s->parameters = params;});
 }
 
 //
@@ -244,59 +333,27 @@ void cable_cell::paint(const std::string& target, mechanism_desc desc) {
 //
 
 //
-// Synapses
+// Synapses.
 //
 
 lid_range cable_cell::place(const std::string& target, const mechanism_desc& desc) {
-    const auto first = impl_->synapses.size();
-
-    const auto it = impl_->locations.find(target);
-    if (it==impl_->locations.end()) return lid_range(first, first);
-
-    return impl_->place(it->second, desc, impl_->synapses);
+    return impl_->place(target, desc, impl_->synapses);
 }
 
-/*
 lid_range cable_cell::place(const locset& ls, const mechanism_desc& desc) {
-    const auto locs = thingify(ls, impl_->morph);
-    return impl_->place(locs, desc, impl_->synapses);
-}
-*/
-
-lid_range cable_cell::place(const mlocation& loc, const mechanism_desc& desc) {
-    if (!impl_->valid_location(loc)) {
-        throw cable_cell_error(util::pprintf(
-            "Attempt to add synapse at invalid location: \"{}\"", loc));
-    }
-    return impl_->place({loc}, desc, impl_->synapses);
+    return impl_->place(thingify(ls, impl_->morph), desc, impl_->synapses);
 }
 
 //
-// Stimuli
+// Stimuli.
 //
 
 lid_range cable_cell::place(const std::string& target, const i_clamp& desc) {
-    const auto first = impl_->stimuli.size();
-
-    const auto it = impl_->locations.find(target);
-    if (it==impl_->locations.end()) return lid_range(first, first);
-
-    return impl_->place(it->second, desc, impl_->stimuli);
+    return impl_->place(target, desc, impl_->stimuli);
 }
 
-/*
 lid_range cable_cell::place(const locset& ls, const i_clamp& desc) {
-    const auto locs = thingify(ls, impl_->morph);
-    return impl_->place(locs, desc, impl_->stimuli);
-}
-*/
-
-lid_range cable_cell::place(const mlocation& loc, const i_clamp& desc) {
-    if (!impl_->valid_location(loc)) {
-        throw cable_cell_error(util::pprintf(
-            "Attempt to add stimulus at invalid location: {}", loc));
-    }
-    return impl_->place({loc}, desc, impl_->stimuli);
+    return impl_->place(thingify(ls, impl_->morph), desc, impl_->stimuli);
 }
 
 //
@@ -304,71 +361,31 @@ lid_range cable_cell::place(const mlocation& loc, const i_clamp& desc) {
 //
 
 lid_range cable_cell::place(const std::string& target, gap_junction_site) {
-    const auto first = impl_->stimuli.size();
-
-    const auto it = impl_->locations.find(target);
-    if (it==impl_->locations.end()) return lid_range(first, first);
-
-    return impl_->place_gj(it->second);
+    return impl_->place_gj(target);
 }
 
-/*
 lid_range cable_cell::place(const locset& ls, gap_junction_site) {
-    const auto locs = thingify(ls, impl_->morph);
-    return impl_->place_gj(locs);
-}
-*/
-
-lid_range cable_cell::place(const mlocation& loc, gap_junction_site) {
-    if (!impl_->valid_location(loc)) {
-        throw cable_cell_error(util::pprintf(
-            "Attempt to add gap junction site at invalid location: {}", loc));
-    }
-    return impl_->place_gj({loc});
+    return impl_->place_gj(thingify(ls, impl_->morph));
 }
 
 //
 // Spike detectors.
 //
 lid_range cable_cell::place(const std::string& target, const threshold_detector& desc) {
-    const auto first = impl_->stimuli.size();
-
-    const auto it = impl_->locations.find(target);
-    if (it==impl_->locations.end()) return lid_range(first, first);
-
-    return impl_->place(it->second, desc.threshold, impl_->spike_detectors);
+    return impl_->place(target, desc.threshold, impl_->spike_detectors);
 }
 
-/*
 lid_range cable_cell::place(const locset& ls, const threshold_detector& desc) {
-    const auto locs = thingify(ls, impl_->morph);
-    return impl_->place(locs, desc.threshold, impl_->spike_detectors);
-}
-*/
-
-lid_range cable_cell::place(const mlocation& loc, const threshold_detector& desc) {
-    if (!impl_->valid_location(loc)) {
-        throw cable_cell_error(util::pprintf(
-            "Attempt to add spike detector at invalid location: {}", loc));
-    }
-    return impl_->place({loc}, desc.threshold, impl_->spike_detectors);
-}
-
-
-soma_segment* cable_cell::soma() {
-    return has_soma()? segment(0)->as_soma(): nullptr;
+    return impl_->place(thingify(ls, impl_->morph), desc.threshold, impl_->spike_detectors);
 }
 
+//
+// TODO: deprectate the following as soon as discretization code catches up with em_morphology
+//
 const soma_segment* cable_cell::soma() const {
     return has_soma()? segment(0)->as_soma(): nullptr;
 }
 
-cable_segment* cable_cell::cable(index_type index) {
-    impl_->assert_valid_segment(index);
-    auto cable = segment(index)->as_cable();
-    return cable? cable: throw cable_cell_error("segment is not a cable segment");
-}
-
 const cable_segment* cable_cell::cable(index_type index) const {
     impl_->assert_valid_segment(index);
     auto cable = segment(index)->as_cable();
@@ -377,21 +394,13 @@ const cable_segment* cable_cell::cable(index_type index) const {
 
 std::vector<size_type> cable_cell::compartment_counts() const {
     std::vector<size_type> comp_count;
-    comp_count.reserve(num_segments());
+    comp_count.reserve(num_branches());
     for (const auto& s: segments()) {
         comp_count.push_back(s->num_compartments());
     }
     return comp_count;
 }
 
-const em_morphology* cable_cell::morphology() const {
-    return &(impl_->morph);
-}
-
-void cable_cell::set_morphology(em_morphology m) {
-    impl_->morph = std::move(m);
-}
-
 size_type cable_cell::num_compartments() const {
     return util::sum_by(impl_->segments,
             [](const segment_ptr& s) { return s->num_compartments(); });
@@ -448,82 +457,4 @@ value_type cable_cell::segment_mean_attenuation(
     return 2*std::sqrt(math::pi<double>*tau_per_um*frequency)*length_factor; // [1/µm]
 }
 
-cable_cell make_cable_cell(const morphology& m,
-                           const label_dict& dictionary,
-                           bool compartments_from_discretization)
-{
-    using point3d = cable_cell::point_type;
-    cable_cell cell;
-
-    if (!m.num_branches()) {
-        return cell;
-    }
-
-    // Add the soma.
-    auto loc = m.samples()[0].loc; // location of soma.
-
-    // If there is no spherical root/soma use a zero-radius soma.
-    double srad = m.spherical_root()? loc.radius: 0.;
-    cell.add_soma(srad, point3d(loc.x, loc.y, loc.z));
-
-    auto& samples = m.samples();
-    for (auto i: util::make_span(1, m.num_branches())) {
-        auto index =  util::make_range(m.branch_indexes(i));
-
-        // find kind for the branch. Use the tag of the last sample in the branch.
-        int tag = samples[index.back()].tag;
-        section_kind kind;
-        switch (tag) {
-            case 1:     // soma
-                throw cable_cell_error("No support for complex somata (yet)");
-            case 2:     // axon
-                kind = section_kind::axon;
-            case 3:     // dendrite
-            case 4:     // apical dendrite
-            default:    // just take dendrite as default
-                kind = section_kind::dendrite;
-        }
-
-        std::vector<value_type> radii;
-        std::vector<point3d> points;
-
-        for (auto i: index) {
-            auto& s = samples[i];
-            radii.push_back(s.loc.radius);
-            points.push_back(point3d(s.loc.x, s.loc.y, s.loc.z));
-        }
-
-        // Find the id of this branch's parent.
-        auto pid = m.branch_parent(i);
-        // Adjust pid if a zero-radius soma was used.
-        if (!m.spherical_root()) {
-            pid = pid==mnpos? 0: pid+1;
-        }
-        auto cable = cell.add_cable(pid, make_segment<cable_segment>(kind, radii, points));
-        if (compartments_from_discretization) {
-            cable->as_cable()->set_compartments(radii.size()-1);
-        }
-    }
-
-    // Construct concrete regions.
-    // Ignores the pointsets, for now.
-    auto em = em_morphology(m); // for converting labels to "segments"
-
-    std::unordered_map<std::string, mcable_list> regions;
-    for (auto r: dictionary.regions()) {
-        regions[r.first] = thingify(r.second, em);
-    }
-    cell.set_regions(std::move(regions));
-
-    std::unordered_map<std::string, mlocation_list> locsets;
-    for (auto l: dictionary.locsets()) {
-        locsets[l.first] = thingify(l.second, em);
-    }
-    cell.set_locsets(std::move(locsets));
-
-    cell.set_morphology(std::move(em));
-
-    return cell;
-}
-
 } // namespace arb
diff --git a/arbor/fvm_layout.cpp b/arbor/fvm_layout.cpp
index fd293f8434685cef4e49a256a44edf4bdfa52f36..b10449e17b41a2b59124cfe3a096f4e8135668c5 100644
--- a/arbor/fvm_layout.cpp
+++ b/arbor/fvm_layout.cpp
@@ -210,7 +210,7 @@ fvm_discretization fvm_discretize(const std::vector<cable_cell>& cells, const ca
     fvm_discretization D;
 
     util::make_partition(D.cell_segment_bounds,
-        transform_view(cells, [](const cable_cell& c) { return c.num_segments(); }));
+        transform_view(cells, [](const cable_cell& c) { return c.num_branches(); }));
 
     std::vector<index_type> cell_cv_bounds;
     auto cell_cv_part = make_partition(cell_cv_bounds,
@@ -261,7 +261,7 @@ fvm_discretization fvm_discretize(const std::vector<cable_cell>& cells, const ca
         seg_cv_bounds.clear();
         auto seg_cv_part = make_partition(
             seg_cv_bounds,
-            transform_view(make_span(c.num_segments()), [&c](const unsigned s) {
+            transform_view(make_span(c.num_branches()), [&c](const unsigned s) {
                 if (!c.segment(s)->is_soma() && c.parent(s)->is_soma()) {
                     return c.segment(s)->num_compartments() + 1;
                 }
diff --git a/arbor/include/arbor/cable_cell.hpp b/arbor/include/arbor/cable_cell.hpp
index 2e35ded6c276c9a441c756b71b1134cc26d3b034..d6c8ff4f2129d7fdbac954f74ba832a58a8bfb90 100644
--- a/arbor/include/arbor/cable_cell.hpp
+++ b/arbor/include/arbor/cable_cell.hpp
@@ -79,39 +79,39 @@ public:
     /// Move constructor
     cable_cell(cable_cell&& other) = default;
 
-    /// add a soma to the cell
-    /// radius must be specified
-    soma_segment* add_soma(value_type radius, point_type center=point_type());
+    /// construct from morphology
+    cable_cell(const class morphology& m,
+               const label_dict& dictionary={},
+               bool compartments_from_discretization=false);
 
-    /// add a cable
-    /// parent is the index of the parent segment for the cable section
-    /// cable is the segment that will be moved into the cell
-    cable_segment* add_cable(index_type parent, segment_ptr&& cable);
+    // the number of branches in the cell
+    size_type num_branches() const;
 
+    // All of the members marked with LEGACY below will be removed once
+    // the discretization code has moved from consuming segments to em_morphology.
+
+    // LEGACY
     bool has_soma() const;
 
-    class segment* segment(index_type index);
+    // LEGACY
     const class segment* parent(index_type index) const;
+    // LEGACY
     const class segment* segment(index_type index) const;
 
     // access pointer to the soma
     // returns nullptr if the cell has no soma
     // LEGACY
-    soma_segment* soma();
     const soma_segment* soma() const;
 
     // access pointer to a cable segment
     // will throw an cable_cell_error exception if
     // the cable index is not valid
     // LEGACY
-    cable_segment* cable(index_type index);
     const cable_segment* cable(index_type index) const;
 
+    // LEGACY
     const std::vector<segment_ptr>& segments() const;
 
-    // the number of segments in the cell
-    size_type num_segments() const;
-
     // return a vector with the compartment count for each segment in the cell
     // LEGACY
     std::vector<size_type> compartment_counts() const;
@@ -128,27 +128,28 @@ public:
     //
 
     // Density channels.
-    void paint(const std::string& target, mechanism_desc);
+    void paint(const std::string&, mechanism_desc);
+    void paint(const region&, mechanism_desc);
+
+    // Properties.
+    void paint(const std::string&, cable_cell_local_parameter_set);
+    void paint(const region&, cable_cell_local_parameter_set);
 
     // Synapses.
-    lid_range place(const std::string& target, const mechanism_desc&);
-    lid_range place(const mlocation&, const mechanism_desc&);  // LEGACY
-    //lid_range place(const locset&, const mechanism_desc&);
+    lid_range place(const std::string&, const mechanism_desc&);
+    lid_range place(const locset&, const mechanism_desc&);
 
     // Stimuli.
-    lid_range place(const std::string& target, const i_clamp&);
-    lid_range place(const mlocation&, const i_clamp&);  // LEGACY
-    //lid_range place(const locset&, const i_clamp&);
+    lid_range place(const std::string&, const i_clamp&);
+    lid_range place(const locset&, const i_clamp&);
 
     // Gap junctions.
     lid_range place(const std::string&, gap_junction_site);
-    lid_range place(const mlocation& loc, gap_junction_site);  // LEGACY
-    //lid_range place(const locset&, gap_junction_site);
+    lid_range place(const locset&, gap_junction_site);
 
     // spike detectors
     lid_range place(const std::string&, const threshold_detector&);
-    lid_range place(const mlocation&, const threshold_detector&);  // LEGACY
-    //lid_range place(const locset&, const threshold_detector&);
+    lid_range place(const locset&, const threshold_detector&);
 
     //
     // access to placed items
@@ -159,13 +160,6 @@ public:
     const std::vector<detector_instance>& detectors() const;
     const std::vector<stimulus_instance>& stimuli() const;
 
-    // These setters are temporary, for "side-loading" in make_cable_cell.
-    // In the regions, locset and morphology descriptions will be passed directly
-    // to the cable_cell constructor.
-    void set_regions(region_map r);
-    void set_locsets(locset_map l);
-    void set_morphology(em_morphology m);
-
     const em_morphology* morphology() const;
 
     // Checks that two cells have the same
@@ -194,12 +188,4 @@ private:
     std::unique_ptr<cable_cell_impl, void (*)(cable_cell_impl*)> impl_;
 };
 
-// Create a cable cell from a morphology specification.
-// If compartments_from_discretization is true, set number of compartments
-// in each segment to be the number of piecewise linear sections in the
-// corresponding section of the morphology.
-cable_cell make_cable_cell(const morphology& morph,
-                           const label_dict& labels={},
-                           bool compartments_from_discretization=false);
-
 } // namespace arb
diff --git a/example/dryrun/branch_cell.hpp b/example/dryrun/branch_cell.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..bbb818188a3694f4c3df060c6744c5a7246fc697
--- /dev/null
+++ b/example/dryrun/branch_cell.hpp
@@ -0,0 +1,124 @@
+#pragma once
+
+#include <array>
+#include <random>
+
+#include <nlohmann/json.hpp>
+
+#include <arbor/common_types.hpp>
+#include <arbor/cable_cell.hpp>
+
+#include <sup/json_params.hpp>
+
+// 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;
+};
+
+cell_parameters parse_cell_parameters(nlohmann::json& json) {
+    cell_parameters params;
+    sup::param_from_json(params.max_depth, "depth", json);
+    sup::param_from_json(params.branch_probs, "branch-probs", json);
+    sup::param_from_json(params.compartments, "compartments", json);
+    sup::param_from_json(params.lengths, "lengths", json);
+    sup::param_from_json(params.synapses, "synapses", json);
+
+    return params;
+}
+
+// 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);
+}
+
+arb::cable_cell branch_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}));
+                }
+            }
+        }
+        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, true);
+
+    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});
+
+    // Add a synapse to the mid point of the first dendrite.
+    cell.place(arb::mlocation{1, 0.5}, "expsyn");
+
+    // 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");
+    }
+
+    return cell;
+}
diff --git a/example/dryrun/dryrun.cpp b/example/dryrun/dryrun.cpp
index ff300d93951699d26a4557598d91ffccafde49cb..ea21ea4ff3a1417672ca1ca78d381355cfc23fc4 100644
--- a/example/dryrun/dryrun.cpp
+++ b/example/dryrun/dryrun.cpp
@@ -27,24 +27,13 @@
 #include <sup/json_meter.hpp>
 #include <sup/json_params.hpp>
 
+#include "branch_cell.hpp"
+
 #ifdef ARB_MPI_ENABLED
 #include <mpi.h>
 #include <arborenv/with_mpi.hpp>
 #endif
 
-// Parameters used to generate the random cell morphologies.
-struct cell_parameters {
-    //  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.
-};
-
 struct run_params {
     std::string name = "default";
     bool dry_run = false;
@@ -175,7 +164,7 @@ struct cell_stats {
             size_type ncomp_tmp = 0;
             for (size_type i=b; i<e; ++i) {
                 auto c = arb::util::any_cast<arb::cable_cell>(r.get_cell_description(i));
-                nsegs_tmp += c.num_segments();
+                nsegs_tmp += c.num_branches();
                 ncomp_tmp += c.num_compartments();
             }
             MPI_Allreduce(&nsegs_tmp, &nsegs, 1, MPI_UNSIGNED, MPI_SUM, MPI_COMM_WORLD);
@@ -187,7 +176,7 @@ struct cell_stats {
             ncells = r.num_cells();
             for (size_type i = 0; i < ncells; ++i) {
                 auto c = arb::util::any_cast<arb::cable_cell>(r.get_cell_description(i));
-                nsegs += c.num_segments();
+                nsegs += c.num_branches();
                 ncomp += c.num_compartments();
             }
         }
@@ -198,7 +187,7 @@ struct cell_stats {
 
             for (size_type i = 0; i < params.num_cells_per_rank; ++i) {
                 auto c = arb::util::any_cast<arb::cable_cell>(r.get_cell_description(i));
-                nsegs += c.num_segments();
+                nsegs += c.num_branches();
                 ncomp += c.num_compartments();
             }
 
@@ -211,12 +200,11 @@ struct cell_stats {
         return o << "cell stats: "
                  << s.nranks << " ranks; "
                  << s.ncells << " cells; "
-                 << s.nsegs << " segments; "
+                 << s.nsegs << " branches; "
                  << s.ncomp << " compartments.";
     }
 };
 
-
 int main(int argc, char** argv) {
     try {
 #ifdef ARB_MPI_ENABLED
@@ -324,67 +312,6 @@ int main(int argc, char** argv) {
     return 0;
 }
 
-// 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);
-}
-
-arb::cable_cell branch_cell(arb::cell_gid_type gid, const cell_parameters& params) {
-    arb::cable_cell cell;
-    cell.default_parameters.axial_resistivity = 100; // [Ω·cm]
-
-    // Add soma.
-    auto soma = cell.add_soma(12.6157/2.0); // For area of 500 μm².
-    soma->add_mechanism("hh");
-
-    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.
-
-    unsigned nsec = 1;
-    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) {
-                    sec_ids.push_back(nsec++);
-                    auto dend = cell.add_cable(sec, arb::make_segment<arb::cable_segment>(arb::section_kind::dendrite, dend_radius, dend_radius, l));
-                    dend->set_compartments(nc);
-                    dend->add_mechanism("pas");
-                }
-            }
-        }
-        if (sec_ids.empty()) {
-            break;
-        }
-        levels.push_back(sec_ids);
-    }
-
-    // Add spike threshold detector at the soma.
-    cell.place(arb::mlocation{0,0}, arb::threshold_detector{10});
-
-    // Add a synapse to the mid point of the first dendrite.
-    cell.place(arb::mlocation{1, 0.5}, "expsyn");
-
-    return cell;
-}
-
 run_params read_options(int argc, char** argv) {
     using sup::param_from_json;
 
@@ -414,10 +341,7 @@ run_params read_options(int argc, char** argv) {
     param_from_json(params.num_ranks, "num-ranks", json);
     param_from_json(params.duration, "duration", json);
     param_from_json(params.min_delay, "min-delay", json);
-    param_from_json(params.cell.max_depth, "depth", json);
-    param_from_json(params.cell.branch_probs, "branch-probs", json);
-    param_from_json(params.cell.compartments, "compartments", json);
-    param_from_json(params.cell.lengths, "lengths", json);
+    params.cell = parse_cell_parameters(json);
 
     if (!json.empty()) {
         for (auto it=json.begin(); it!=json.end(); ++it) {
diff --git a/example/gap_junctions/gap_junctions.cpp b/example/gap_junctions/gap_junctions.cpp
index 4e3aa846f75176670c1060b23abab22c5812b617..32c789d9950c31ea81f491fdc11808a4110e94ad 100644
--- a/example/gap_junctions/gap_junctions.cpp
+++ b/example/gap_junctions/gap_junctions.cpp
@@ -161,7 +161,7 @@ struct cell_stats {
         size_type ncomp_tmp = 0;
         for (size_type i=b; i<e; ++i) {
             auto c = arb::util::any_cast<arb::cable_cell>(r.get_cell_description(i));
-            nsegs_tmp += c.num_segments();
+            nsegs_tmp += c.num_branches();
             ncomp_tmp += c.num_compartments();
         }
         MPI_Allreduce(&nsegs_tmp, &nsegs, 1, MPI_UNSIGNED, MPI_SUM, MPI_COMM_WORLD);
@@ -170,7 +170,7 @@ struct cell_stats {
         ncells = r.num_cells();
         for (size_type i=0; i<ncells; ++i) {
             auto c = arb::util::any_cast<arb::cable_cell>(r.get_cell_description(i));
-            nsegs += c.num_segments();
+            nsegs += c.num_branches();
             ncomp += c.num_compartments();
         }
 #endif
@@ -179,7 +179,7 @@ struct cell_stats {
     friend std::ostream& operator<<(std::ostream& o, const cell_stats& s) {
         return o << "cell stats: "
                  << s.ncells << " cells; "
-                 << s.nsegs << " segments; "
+                 << s.nsegs << " branchess; "
                  << s.ncomp << " compartments.";
     }
 };
@@ -334,45 +334,52 @@ void write_trace_json(const std::vector<arb::trace_data<double>>& trace, unsigne
 }
 
 arb::cable_cell gj_cell(cell_gid_type gid, unsigned ncell, double stim_duration) {
-    arb::cable_cell cell;
+    // Create the sample tree that defines the morphology.
+    arb::sample_tree tree;
+    double soma_rad = 22.360679775/2.0; // convert diameter to radius in μm
+    tree.append({{0,0,0,soma_rad}, 1}); // soma is a single sample point
+    double dend_rad = 3./2;
+    tree.append(0, {{0,0,soma_rad,     dend_rad}, 3});  // proximal point of the dendrite
+    tree.append(1, {{0,0,soma_rad+300, dend_rad}, 3});  // distal end of the dendrite
+
+    // Create a label dictionary that creates a single region that covers the whole cell.
+    arb::label_dict d;
+    d.set("all",  arb::reg::all());
+
+    // Create the cell and set its electrical properties.
+    arb::cable_cell cell(tree, d);
     cell.default_parameters.axial_resistivity = 100;       // [Ω·cm]
     cell.default_parameters.membrane_capacitance = 0.018;  // [F/m²]
 
+    // Define the density channels and their parameters.
     arb::mechanism_desc nax("nax");
+    nax["gbar"] = 0.04;
+    nax["sh"] = 10;
+
     arb::mechanism_desc kdrmt("kdrmt");
+    kdrmt["gbar"] = 0.0001;
+
     arb::mechanism_desc kamt("kamt");
+    kamt["gbar"] = 0.004;
+
     arb::mechanism_desc pas("pas");
+    pas["g"] =  1.0/12000.0;
+    pas["e"] =  -65;
 
-    auto set_reg_params = [&]() {
-        nax["gbar"] = 0.04;
-        nax["sh"] = 10;
-        kdrmt["gbar"] = 0.0001;
-        kamt["gbar"] = 0.004;
-        pas["g"] =  1.0/12000.0;
-        pas["e"] =  -65;
-    };
-
-    auto setup_seg = [&](auto seg) {
-        seg->add_mechanism(nax);
-        seg->add_mechanism(kdrmt);
-        seg->add_mechanism(kamt);
-        seg->add_mechanism(pas);
-    };
-
-    auto soma = cell.add_soma(22.360679775/2.0);
-    set_reg_params();
-    setup_seg(soma);
-
-    auto dend = cell.add_cable(0, arb::make_segment<arb::cable_segment>(arb::section_kind::dendrite, 3.0/2.0, 3.0/2.0, 300)); //cable 1
-    dend->set_compartments(1);
-    set_reg_params();
-    setup_seg(dend);
+    // Paint density channels on all parts of the cell
+    cell.paint("all", nax);
+    cell.paint("all", kdrmt);
+    cell.paint("all", kamt);
+    cell.paint("all", pas);
 
+    // Add a spike detector to the soma.
     cell.place(arb::mlocation{0,0}, arb::threshold_detector{10});
 
+    // Add two gap junction sites.
     cell.place(arb::mlocation{0, 1}, arb::gap_junction_site{});
     cell.place(arb::mlocation{1, 1}, arb::gap_junction_site{});
 
+    // Attach a stimulus to the second cell.
     if (!gid) {
         arb::i_clamp stim(0, stim_duration, 0.4);
         cell.place(arb::mlocation{0, 0.5}, stim);
diff --git a/example/generators/generators.cpp b/example/generators/generators.cpp
index 9d48f0c6bb1f999a2785eb564219c3720b44b13a..e5234ec96980dae656103e1697d7ed7389ba58d5 100644
--- a/example/generators/generators.cpp
+++ b/example/generators/generators.cpp
@@ -47,10 +47,15 @@ public:
     //    capacitance: 0.01 F/m² [default]
     //    synapses: 1 * expsyn
     arb::util::unique_any get_cell_description(cell_gid_type gid) const override {
-        arb::cable_cell c;
+        arb::sample_tree tree;
+        double r = 18.8/2.0; // convert 18.8 μm diameter to radius
+        tree.append({{0,0,0,r}, 1});
 
-        c.add_soma(18.8/2.0); // convert 18.8 μm diameter to radius
-        c.soma()->add_mechanism("pas");
+        arb::label_dict d;
+        d.set("soma", arb::reg::tagged(1));
+
+        arb::cable_cell c(tree, d);
+        c.paint("soma", "pas");
 
         // Add one synapse at the soma.
         // This synapse will be the target for all events, from both
diff --git a/example/ring/branch_cell.hpp b/example/ring/branch_cell.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..bbb818188a3694f4c3df060c6744c5a7246fc697
--- /dev/null
+++ b/example/ring/branch_cell.hpp
@@ -0,0 +1,124 @@
+#pragma once
+
+#include <array>
+#include <random>
+
+#include <nlohmann/json.hpp>
+
+#include <arbor/common_types.hpp>
+#include <arbor/cable_cell.hpp>
+
+#include <sup/json_params.hpp>
+
+// 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;
+};
+
+cell_parameters parse_cell_parameters(nlohmann::json& json) {
+    cell_parameters params;
+    sup::param_from_json(params.max_depth, "depth", json);
+    sup::param_from_json(params.branch_probs, "branch-probs", json);
+    sup::param_from_json(params.compartments, "compartments", json);
+    sup::param_from_json(params.lengths, "lengths", json);
+    sup::param_from_json(params.synapses, "synapses", json);
+
+    return params;
+}
+
+// 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);
+}
+
+arb::cable_cell branch_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}));
+                }
+            }
+        }
+        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, true);
+
+    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});
+
+    // Add a synapse to the mid point of the first dendrite.
+    cell.place(arb::mlocation{1, 0.5}, "expsyn");
+
+    // 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");
+    }
+
+    return cell;
+}
diff --git a/example/ring/ring.cpp b/example/ring/ring.cpp
index ac7d344f304f569a6a7dbba52a08c200db0b748d..2220d6f4eb0ac84d8da60dcf2578b2f27b29bf3e 100644
--- a/example/ring/ring.cpp
+++ b/example/ring/ring.cpp
@@ -29,29 +29,13 @@
 #include <sup/json_meter.hpp>
 #include <sup/json_params.hpp>
 
+#include "branch_cell.hpp"
+
 #ifdef ARB_MPI_ENABLED
 #include <mpi.h>
 #include <arborenv/with_mpi.hpp>
 #endif
 
-// 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;
-};
-
 struct ring_params {
     ring_params() = default;
 
@@ -171,7 +155,7 @@ struct cell_stats {
         size_type ncomp_tmp = 0;
         for (size_type i=b; i<e; ++i) {
             auto c = arb::util::any_cast<arb::cable_cell>(r.get_cell_description(i));
-            nsegs_tmp += c.num_segments();
+            nsegs_tmp += c.num_branches();
             ncomp_tmp += c.num_compartments();
         }
         MPI_Allreduce(&nsegs_tmp, &nsegs, 1, MPI_UNSIGNED, MPI_SUM, MPI_COMM_WORLD);
@@ -180,7 +164,7 @@ struct cell_stats {
         ncells = r.num_cells();
         for (size_type i=0; i<ncells; ++i) {
             auto c = arb::util::any_cast<arb::cable_cell>(r.get_cell_description(i));
-            nsegs += c.num_segments();
+            nsegs += c.num_branches();
             ncomp += c.num_compartments();
         }
 #endif
@@ -189,7 +173,7 @@ struct cell_stats {
     friend std::ostream& operator<<(std::ostream& o, const cell_stats& s) {
         return o << "cell stats: "
                  << s.ncells << " cells; "
-                 << s.nsegs << " segments; "
+                 << s.nsegs << " branches; "
                  << s.ncomp << " compartments.";
     }
 };
@@ -327,72 +311,6 @@ void write_trace_json(const arb::trace_data<double>& trace) {
     file << std::setw(1) << json << "\n";
 }
 
-// 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);
-}
-
-arb::cable_cell branch_cell(arb::cell_gid_type gid, const cell_parameters& params) {
-    arb::cable_cell cell;
-    cell.default_parameters.axial_resistivity = 100; // [Ω·cm]
-
-    // Add soma.
-    auto soma = cell.add_soma(12.6157/2.0); // For area of 500 μm².
-    soma->add_mechanism("hh");
-
-    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.
-
-    unsigned nsec = 1;
-    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) {
-                    sec_ids.push_back(nsec++);
-                    auto dend = cell.add_cable(sec, arb::make_segment<arb::cable_segment>(arb::section_kind::dendrite, dend_radius, dend_radius, l));
-                    dend->set_compartments(nc);
-                    dend->add_mechanism("pas");
-                }
-            }
-        }
-        if (sec_ids.empty()) {
-            break;
-        }
-        levels.push_back(sec_ids);
-    }
-
-    // Add spike threshold detector at the soma.
-    cell.place(arb::mlocation{0,0}, arb::threshold_detector{10});
-
-    // Add a synapse to the mid point of the first dendrite.
-    cell.place(arb::mlocation{1, 0.5}, "expsyn");
-
-    // 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");
-    }
-
-    return cell;
-}
-
 ring_params read_options(int argc, char** argv) {
     using sup::param_from_json;
 
@@ -420,11 +338,7 @@ ring_params read_options(int argc, char** argv) {
     param_from_json(params.num_cells, "num-cells", json);
     param_from_json(params.duration, "duration", json);
     param_from_json(params.min_delay, "min-delay", json);
-    param_from_json(params.cell.max_depth, "depth", json);
-    param_from_json(params.cell.branch_probs, "branch-probs", json);
-    param_from_json(params.cell.compartments, "compartments", json);
-    param_from_json(params.cell.lengths, "lengths", json);
-    param_from_json(params.cell.synapses, "synapses", json);
+    params.cell = parse_cell_parameters(json);
 
     if (!json.empty()) {
         for (auto it=json.begin(); it!=json.end(); ++it) {
diff --git a/example/single/single.cpp b/example/single/single.cpp
index 501969f5de77ca9aafbe9fc7853cfd0945e248a5..3e7eb01758e13d17f68d09d7560be4b86f2f97e3 100644
--- a/example/single/single.cpp
+++ b/example/single/single.cpp
@@ -58,26 +58,28 @@ struct single_recipe: public arb::recipe {
         using arb::reg::tagged;
         dict.set("soma", tagged(1));
         dict.set("dend", join(tagged(3), tagged(4), tagged(42)));
-        arb::cable_cell c = make_cable_cell(morpho, dict, false);
+        arb::cable_cell c(morpho, dict, false);
 
         // Add HH mechanism to soma, passive channels to dendrites.
         c.paint("soma", "hh");
         c.paint("dend", "pas");
 
         // Discretize dendrites according to the NEURON d-lambda rule.
-        for (std::size_t i=1; i<c.num_segments(); ++i) {
+        /* skip this during refactoring of the morphology interface
+        for (std::size_t i=1; i<c.num_branches(); ++i) {
             arb::cable_segment* branch = c.cable(i);
 
             double dx = c.segment_length_constant(100., i, gprop.default_parameters)*0.3;
             unsigned n = std::ceil(branch->length()/dx);
             branch->set_compartments(n);
         }
+        */
 
         // Add synapse to last branch.
 
-        arb::cell_lid_type last_segment = c.num_segments()-1;
-        arb::mlocation end_last_segment = { last_segment, 1. };
-        c.place(end_last_segment, "exp2syn");
+        arb::cell_lid_type last_branch = c.num_branches()-1;
+        arb::mlocation end_last_branch = { last_branch, 1. };
+        c.place(end_last_branch, "exp2syn");
 
         return c;
     }
diff --git a/python/cells.cpp b/python/cells.cpp
index 21a93db2d8df0b9f6f7c7fc0c38d002e1df48d0f..8150dffe46313ff7f01c2d56ffb39bb81a4512d1 100644
--- a/python/cells.cpp
+++ b/python/cells.cpp
@@ -84,13 +84,11 @@ double interp(const std::array<T,2>& r, unsigned i, unsigned n) {
 }
 
 arb::cable_cell make_cable_cell(arb::cell_gid_type gid, const cell_parameters& params) {
-    arb::cable_cell cell;
-
-    cell.default_parameters.axial_resistivity = 100;
+    arb::sample_tree tree;
 
     // Add soma.
-    auto soma = cell.add_soma(12.6157/2.0); // For area of 500 μm².
-    soma->add_mechanism("hh");
+    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});
@@ -101,7 +99,7 @@ arb::cable_cell make_cable_cell(arb::cell_gid_type gid, const cell_parameters& p
 
     double dend_radius = 0.5; // Diameter of 1 μm for each cable.
 
-    unsigned nsec = 1;
+    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);
@@ -114,10 +112,15 @@ arb::cable_cell make_cable_cell(arb::cell_gid_type gid, const cell_parameters& p
         for (unsigned sec: levels[i]) {
             for (unsigned j=0; j<2; ++j) {
                 if (dis(gen)<bp) {
-                    sec_ids.push_back(nsec++);
-                    auto dend = cell.add_cable(sec, arb::make_segment<arb::cable_segment>(arb::section_kind::dendrite, dend_radius, dend_radius, l));
-                    dend->set_compartments(nc);
-                    dend->add_mechanism("pas");
+                    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}));
                 }
             }
         }
@@ -125,15 +128,29 @@ arb::cable_cell make_cable_cell(arb::cell_gid_type gid, const cell_parameters& p
             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, true);
+
+    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});
 
     // Add a synapse to the mid point of the first dendrite.
     cell.place(arb::mlocation{1, 0.5}, "expsyn");
 
-    // Add additional synapses.
+    // 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");
     }
diff --git a/sup/include/sup/json_params.hpp b/sup/include/sup/json_params.hpp
index 7903b6cd6478a61af7a667b588519925721c6d48..fdcf0a20e2be4df7bffa7ec8ca06a5b457743c33 100644
--- a/sup/include/sup/json_params.hpp
+++ b/sup/include/sup/json_params.hpp
@@ -1,3 +1,5 @@
+#pragma once
+
 #include <array>
 #include <exception>
 
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index ef725caab660680f7b503a439d1f29f76f817178..7010a1a71af252a4183740d2f1dc1ee6dbf984e8 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -13,10 +13,6 @@ add_custom_target(tests)
 # Builds: unit.
 add_subdirectory(unit)
 
-# Model validation.
-# Builds: validate.
-add_subdirectory(validation)
-
 # Test MPI wrappers and distribution operations.
 # Builds: unit-local and unit-mpi (if MPI enabled).
 add_subdirectory(unit-distributed)
diff --git a/test/common_cells.hpp b/test/common_cells.hpp
index dd40bc91d0add02c8f101651d685d1bc6ab8a316..02821167bc0c550413c8b763bf4c2af1fbc5468f 100644
--- a/test/common_cells.hpp
+++ b/test/common_cells.hpp
@@ -3,10 +3,88 @@
 #include <arbor/cable_cell.hpp>
 #include <arbor/segment.hpp>
 #include <arbor/mechinfo.hpp>
+#include <arbor/morph/label_dict.hpp>
 #include <arbor/recipe.hpp>
 
 namespace arb {
 
+class soma_cell_builder {
+    double soma_rad;
+    sample_tree tree;
+    std::vector<msize_t> branch_distal_id;
+    std::unordered_map<std::string, int> tag_map;
+    int tag_count = 0;
+
+    // Get tag id of region.
+    // 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) {
+        auto it = tag_map.find(name);
+        // If the name is not in the map, make a unique tag.
+        // Tags start from 1.
+        if (it==tag_map.end()) {
+            tag_map[name] = ++tag_count;
+            return tag_count;
+        }
+        return it->second;
+    }
+
+public:
+    soma_cell_builder(double r): soma_rad(r) {
+        tree.append({{0,0,0,r}, get_tag("soma")});
+        branch_distal_id.push_back(0);
+    }
+
+    // Add a new branch that is attached to parent_branch.
+    // Returns the id of the new branch.
+    msize_t add_branch(msize_t parent_branch, double len, double r1, double r2, int ncomp,
+                    const std::string& region)
+    {
+        // Get tag id of region (add a new tag if region does not already exist).
+        int tag = get_tag(region);
+
+        msize_t p = branch_distal_id[parent_branch];
+        double z = parent_branch? tree.samples()[p].loc.z: soma_rad;
+
+        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});
+        branch_distal_id.push_back(p);
+
+        return branch_distal_id.size()-1;
+    }
+
+    cable_cell make_cell() const {
+        // Test that a valid tree was generated, that is, every branch has
+        // either 0 children, or at least 2 children.
+        for (auto i: branch_distal_id) {
+            if (i==0) continue;
+            auto prop = tree.properties()[i];
+            if (!is_fork(prop) && !is_terminal(prop)) {
+                throw cable_cell_error(
+                    "attempt to construct a cable_cell from a soma_cell_builder "
+                    "where a branch has only one child branch.");
+            }
+        }
+
+        // Make label dictionary with one entry for each tag.
+        label_dict dict;
+        for (auto& tag: tag_map) {
+            dict.set(tag.first, reg::tagged(tag.second));
+        }
+
+        // Make cable_cell from sample tree and dictionary.
+        // The true flag is used to force the discretization to make compartments
+        // at sample points.
+        return cable_cell(tree, dict, true);
+    }
+};
+
 /*
  * Create cell with just a soma:
  *
@@ -21,11 +99,10 @@ namespace arb {
  */
 
 inline cable_cell make_cell_soma_only(bool with_stim = true) {
-    cable_cell c;
-
-    auto soma = c.add_soma(18.8/2.0);
-    soma->add_mechanism("hh");
+    soma_cell_builder builder(18.8/2.0);
 
+    auto c = builder.make_cell();
+    c.paint("soma", "hh");
     if (with_stim) {
         c.place(mlocation{0,0.5}, i_clamp{10., 100., 0.1});
     }
@@ -55,125 +132,16 @@ inline cable_cell make_cell_soma_only(bool with_stim = true) {
  */
 
 inline cable_cell make_cell_ball_and_stick(bool with_stim = true) {
-    cable_cell c;
-
-    auto soma = c.add_soma(12.6157/2.0);
-    soma->add_mechanism("hh");
-
-    c.add_cable(0, make_segment<cable_segment>(section_kind::dendrite, 1.0/2, 1.0/2, 200.0));
-
-    for (auto& seg: c.segments()) {
-        if (seg->is_dendrite()) {
-            seg->add_mechanism("pas");
-            seg->set_compartments(4);
-        }
-    }
+    soma_cell_builder builder(12.6157/2.0);
+    builder.add_branch(0, 200, 1.0/2, 1.0/2, 4, "dend");
 
+    auto c = builder.make_cell();
+    c.paint("soma", "hh");
+    c.paint("dend", "pas");
     if (with_stim) {
-        c.place(mlocation{1,1}, i_clamp{5., 80., 0.3});
-    }
-    return c;
-}
-
-/*
- * Create cell with a soma and unbranched tapered dendrite:
- *
- * Common properties:
- *    bulk resistivity: 100 Ω·cm [default]
- *    capacitance: 0.01 F/m² [default]
- *
- * Soma:
- *    mechanisms: HH (default params)
- *    diameter: 12.6157 µm
- *
- * Dendrite:
- *    mechanisms: passive (default params)
- *    diameter proximal: 1 µm
- *    diameter distal: 0.4 µm
- *    length: 200 µm
- *    bulk resistivity: 100 Ω·cm
- *    compartments: 4
- *
- * Stimulus:
- *    end of dendrite, t=[5 ms, 85 ms), 0.3 nA
- */
-
-inline cable_cell make_cell_ball_and_taper(bool with_stim = true) {
-    cable_cell c;
-
-    auto soma = c.add_soma(12.6157/2.0);
-    soma->add_mechanism("hh");
-
-    c.add_cable(0, make_segment<cable_segment>(section_kind::dendrite, 1.0/2, 0.4/2, 200.0));
-
-    for (auto& seg: c.segments()) {
-        if (seg->is_dendrite()) {
-            seg->add_mechanism("pas");
-            seg->set_compartments(4);
-        }
+        c.place(mlocation{1,1}, i_clamp{5, 80, 0.3});
     }
 
-    if (with_stim) {
-        c.place(mlocation{1,1}, i_clamp{5., 80., 0.3});
-    }
-    return c;
-}
-
-/*
- * Create cell with a soma and unbranched dendrite with varying diameter:
- *
- * Common properties:
- *    bulk resistivity: 100 Ω·cm [default]
- *    capacitance: 0.01 F/m² [default]
- *
- * Soma:
- *    mechanisms: HH
- *    diameter: 12.6157 µm
- *
- * Dendrite:
- *    mechanisms: passive (default params)
- *    length: 100 µm
- *    membrane resistance: 100 Ω·cm
- *    compartments: 4
- *
- * Stimulus:
- *    end of dendrite, t=[5 ms, 85 ms), 0.3 nA
- */
-
-inline cable_cell make_cell_ball_and_squiggle(bool with_stim = true) {
-    cable_cell c;
-
-    auto soma = c.add_soma(12.6157/2.0);
-    soma->add_mechanism("hh");
-
-    std::vector<cable_cell::value_type> radii;
-    std::vector<cable_cell::point_type> points;
-
-    double length = 100.0;
-    int npoints = 200;
-
-    for (int i=0; i<npoints; ++i) {
-        double x = i*(1.0/(npoints-1));
-        double r = std::exp(-x)*(std::sin(40*x)*0.05+0.1)+0.1;
-
-        radii.push_back(r);
-        points.push_back({x*length, 0., 0.});
-    };
-
-    auto dendrite =
-        make_segment<cable_segment>(section_kind::dendrite, radii, points);
-    c.add_cable(0, std::move(dendrite));
-
-    for (auto& seg: c.segments()) {
-        if (seg->is_dendrite()) {
-            seg->add_mechanism("pas");
-            seg->set_compartments(4);
-        }
-    }
-
-    if (with_stim) {
-        c.place(mlocation{1,1}, i_clamp{5., 80., 0.3});
-    }
     return c;
 }
 
@@ -202,78 +170,20 @@ inline cable_cell make_cell_ball_and_squiggle(bool with_stim = true) {
  */
 
 inline cable_cell make_cell_ball_and_3stick(bool with_stim = true) {
-    cable_cell c;
-
-    auto soma = c.add_soma(12.6157/2.0);
-    soma->add_mechanism("hh");
-
-    c.add_cable(0, make_segment<cable_segment>(section_kind::dendrite, 0.5, 0.5, 100));
-    c.add_cable(1, make_segment<cable_segment>(section_kind::dendrite, 0.5, 0.5, 100));
-    c.add_cable(1, make_segment<cable_segment>(section_kind::dendrite, 0.5, 0.5, 100));
-
-    for (auto& seg: c.segments()) {
-        if (seg->is_dendrite()) {
-            seg->add_mechanism("pas");
-            seg->set_compartments(4);
-        }
-    }
-
+    soma_cell_builder builder(12.6157/2.0);
+    builder.add_branch(0, 100, 0.5, 0.5, 4, "dend");
+    builder.add_branch(1, 100, 0.5, 0.5, 4, "dend");
+    builder.add_branch(1, 100, 0.5, 0.5, 4, "dend");
+
+    auto c = builder.make_cell();
+    c.paint("soma", "hh");
+    c.paint("dend", "pas");
     if (with_stim) {
         c.place(mlocation{2,1}, i_clamp{5.,  80., 0.45});
         c.place(mlocation{3,1}, i_clamp{40., 10.,-0.2});
     }
-    return c;
-}
-
-/*
- * Create 'soma-less' cell with single cable, with physical
- * parameters from Rallpack 1 model.
- *
- * Common properties:
- *    mechanisms: passive
- *        membrane conductance: 0.000025 S/cm² ( =  1/(4Ω·m²) )
- *        membrane reversal potential: -65 mV (default)
- *    diameter: 1 µm
- *    length: 1000 µm
- *    bulk resistivity: 100 Ω·cm [default]
- *    capacitance: 0.01 F/m² [default]
- *    compartments: 4
- *
- * Stimulus:
- *    end of dendrite, t=[0 ms, inf), 0.1 nA
- *
- * Note: zero-volume soma added with same mechanisms, as
- * work-around for some existing fvm modelling issues.
- */
-
-inline cable_cell make_cell_simple_cable(bool with_stim = true) {
-    cable_cell c;
-
-    c.default_parameters.axial_resistivity = 100;
-    c.default_parameters.membrane_capacitance = 0.01;
-
-    c.add_soma(0);
-    c.add_cable(0, make_segment<cable_segment>(section_kind::dendrite, 0.5, 0.5, 1000));
-
-    double gbar = 0.000025;
-    double I = 0.1;
-
-    mechanism_desc pas("pas");
-    pas["g"] = gbar;
-
-    for (auto& seg: c.segments()) {
-        seg->add_mechanism(pas);
-
-        if (seg->is_dendrite()) {
-            seg->set_compartments(4);
-        }
-    }
 
-    if (with_stim) {
-        // stimulus in the middle of our zero-volume 'soma'
-        // corresponds to proximal end of cable.
-        c.place(mlocation{0,0.5}, i_clamp{0., INFINITY, I});
-    }
     return c;
 }
+
 } // namespace arb
diff --git a/test/unit/test_cable_cell.cpp b/test/unit/test_cable_cell.cpp
index 49d16a9e74b4e82d79f4a7f4b7350d0e8423db96..62c54706886dee11cf1b5ba829e9c9ede03fb1b1 100644
--- a/test/unit/test_cable_cell.cpp
+++ b/test/unit/test_cable_cell.cpp
@@ -4,6 +4,8 @@
 #include <arbor/math.hpp>
 #include <arbor/morph/locset.hpp>
 
+#include "io/sepval.hpp"
+
 #include "tree.hpp"
 
 using namespace arb;
@@ -11,226 +13,69 @@ using ::arb::math::pi;
 
 TEST(cable_cell, soma) {
     // test that insertion of a soma works
-    //      define with no centre point
-    {
-        cable_cell c;
-        auto soma_radius = 2.1;
+    //      define with centre point @ (0,0,1)
+    double soma_radius = 3.2;
 
-        EXPECT_EQ(c.has_soma(), false);
-        c.add_soma(soma_radius);
-        EXPECT_EQ(c.has_soma(), true);
+    arb::sample_tree samples;
+    samples.append({0,0,0,soma_radius,1});
+    auto c = cable_cell(arb::morphology(samples));
 
-        auto s = c.soma();
-        EXPECT_EQ(s->radius(), soma_radius);
-        EXPECT_EQ(s->center().is_set(), false);
-    }
+    EXPECT_EQ(c.has_soma(), true);
 
-    // test that insertion of a soma works
-    //      define with centre point @ (0,0,1)
-    {
-        cable_cell c;
-        auto soma_radius = 3.2;
-
-        EXPECT_EQ(c.has_soma(), false);
-        c.add_soma(soma_radius, {0,0,1});
-        EXPECT_EQ(c.has_soma(), true);
-
-        auto s = c.soma();
-        EXPECT_EQ(s->radius(), soma_radius);
-        EXPECT_EQ(s->center().is_set(), true);
-    }
-}
-
-TEST(cable_cell, add_segment) {
-    //  add a pre-defined segment
-    {
-        cable_cell c;
-
-        auto soma_radius  = 2.1;
-        auto cable_radius = 0.1;
-        auto cable_length = 8.3;
-
-        // add a soma because we need something to attach the first dendrite to
-        c.add_soma(soma_radius, {0,0,1});
-
-        auto seg =
-            make_segment<cable_segment>(
-                section_kind::dendrite,
-                cable_radius, cable_radius, cable_length
-            );
-        c.add_cable(0, std::move(seg));
-
-        EXPECT_EQ(c.num_segments(), 2u);
-    }
-
-    //  add segment on the fly
-    {
-        cable_cell c;
-
-        auto soma_radius  = 2.1;
-        auto cable_radius = 0.1;
-        auto cable_length = 8.3;
-
-        // add a soma because we need something to attach the first dendrite to
-        c.add_soma(soma_radius, {0,0,1});
-
-        c.add_cable(
-            0,
-            make_segment<cable_segment>(section_kind::dendrite, cable_radius, cable_radius, cable_length)
-        );
-
-        EXPECT_EQ(c.num_segments(), 2u);
-    }
-    {
-        cable_cell c;
-
-        auto soma_radius  = 2.1;
-        auto cable_radius = 0.1;
-        auto cable_length = 8.3;
-
-        // add a soma because we need something to attach the first dendrite to
-        c.add_soma(soma_radius, {0,0,1});
-
-        c.add_cable(
-            0,
-            make_segment<cable_segment>(section_kind::dendrite,
-                                        std::vector<double>{cable_radius, cable_radius, cable_radius, cable_radius},
-                                        std::vector<double>{cable_length, cable_length, cable_length})
-        );
-
-        EXPECT_EQ(c.num_segments(), 2u);
-    }
+    auto s = c.soma();
+    EXPECT_EQ(s->radius(), soma_radius);
 }
 
 TEST(cable_cell, multiple_cables) {
-    // generate a cylindrical cable segment of length 1/pi and radius 1
+    double soma_radius = std::pow(3./(4.*pi<double>), 1./3.);
+
+    // Generate a cylindrical cable segment of length 1/pi and radius 1
     //      volume = 1
     //      area   = 2
-    auto seg = [](section_kind k) {
-        return make_segment<cable_segment>( k, 1.0, 1.0, 1./pi<double> );
+    // Returns the distal point of the added cable.
+    auto append_branch = [soma_radius](sample_tree& stree, int proximal) {
+        constexpr int tag = 2;
+        if (!proximal) {
+            double z = soma_radius;
+            proximal = stree.append(0, {0,0,z, 1/pi<double>, tag});
+        }
+        return stree.append(proximal, msample{0, 0, stree.samples()[proximal].loc.z+1, 1/pi<double>, tag});
     };
 
-    //  add a pre-defined segment
-    {
-        cable_cell c;
-
-        auto soma_radius = std::pow(3./(4.*pi<double>), 1./3.);
-
-        // cell strucure as follows
-        // left   :  segment numbering
-        // right  :  segment type (soma, axon, dendrite)
-        //
-        //          0           s
-        //         / \         / \.
-        //        1   2       d   a
-        //       / \         / \.
-        //      3   4       d   d
-
-        // add a soma
-        c.add_soma(soma_radius, {0,0,1});
-
-        // hook the dendrite and axons
-        c.add_cable(0, seg(section_kind::dendrite));
-        c.add_cable(0, seg(section_kind::axon));
-        c.add_cable(1, seg(section_kind::dendrite));
-        c.add_cable(1, seg(section_kind::dendrite));
-
-        EXPECT_EQ(c.num_segments(), 5u);
-
-        // construct the graph
-        tree con(c.parents());
-
-        auto no_parent = tree::no_parent;
-        EXPECT_EQ(con.num_segments(), 5u);
-        EXPECT_EQ(con.parent(0), no_parent);
-        EXPECT_EQ(con.parent(1), 0u);
-        EXPECT_EQ(con.parent(2), 0u);
-        EXPECT_EQ(con.parent(3), 1u);
-        EXPECT_EQ(con.parent(4), 1u);
-        EXPECT_EQ(con.num_children(0), 2u);
-        EXPECT_EQ(con.num_children(1), 2u);
-        EXPECT_EQ(con.num_children(2), 0u);
-        EXPECT_EQ(con.num_children(3), 0u);
-        EXPECT_EQ(con.num_children(4), 0u);
-
-    }
-}
-
-TEST(cable_cell, unbranched_chain) {
-    cable_cell c;
-
-    auto soma_radius = std::pow(3./(4.*pi<double>), 1./3.);
-
-    // Cell strucure that looks like a centipede: i.e. each segment has only one child
+    // cell strucure with branch numbers
     //
-    //   |       |
-    //  0|1-2-3-4|5-6-7-8
-    //   |       |
+    //          0
+    //         / \.
+    //        1   2
+    //       / \.
+    //      3   4
 
-    // add a soma
-    c.add_soma(soma_radius, {0,0,1});
+    arb::sample_tree samples;
+    samples.append({0,0,-soma_radius,soma_radius,1});
 
     // hook the dendrite and axons
-    c.add_cable(0, make_segment<cable_segment>(section_kind::dendrite, 1.0, 1.0, 1./pi<double>));
-    c.add_cable(1, make_segment<cable_segment>(section_kind::dendrite, 1.0, 1.0, 1./pi<double>));
+    append_branch(samples, 0);
+    append_branch(samples, 0);
+    append_branch(samples, 2);
+    append_branch(samples, 2);
+
+    auto c = cable_cell(arb::morphology(samples, true));
 
-    EXPECT_EQ(c.num_segments(), 3u);
+    EXPECT_EQ(c.num_branches(), 5u);
 
     // construct the graph
     tree con(c.parents());
 
     auto no_parent = tree::no_parent;
-    EXPECT_EQ(con.num_segments(), 3u);
+    EXPECT_EQ(con.num_segments(), 5u);
     EXPECT_EQ(con.parent(0), no_parent);
     EXPECT_EQ(con.parent(1), 0u);
-    EXPECT_EQ(con.parent(2), 1u);
-    EXPECT_EQ(con.num_children(0), 1u);
-    EXPECT_EQ(con.num_children(1), 1u);
+    EXPECT_EQ(con.parent(2), 0u);
+    EXPECT_EQ(con.parent(3), 1u);
+    EXPECT_EQ(con.parent(4), 1u);
+    EXPECT_EQ(con.num_children(0), 2u);
+    EXPECT_EQ(con.num_children(1), 2u);
     EXPECT_EQ(con.num_children(2), 0u);
-}
-
-TEST(cable_cell, clone) {
-    // make simple cell with multiple segments
-
-    cable_cell c;
-    c.add_soma(2.1);
-    c.add_cable(0, make_segment<cable_segment>(section_kind::dendrite, 0.3, 0.2, 10));
-    c.segment(1)->set_compartments(3);
-    c.add_cable(1, make_segment<cable_segment>(section_kind::dendrite, 0.2, 0.15, 20));
-    c.segment(2)->set_compartments(5);
-
-    c.place(mlocation{1, 0.3}, "expsyn");
-    c.place(mlocation{0, 0.5}, threshold_detector{10.0});
-
-    // make clone
-
-    cable_cell d(c);
-
-    // check equality
-
-    ASSERT_EQ(c.num_segments(), d.num_segments());
-    EXPECT_EQ(c.soma()->radius(), d.soma()->radius());
-    EXPECT_EQ(c.segment(1)->as_cable()->length(), d.segment(1)->as_cable()->length());
-    {
-        const auto& csyns = c.synapses();
-        const auto& dsyns = d.synapses();
-
-        ASSERT_EQ(csyns.size(), dsyns.size());
-        for (unsigned i=0; i<csyns.size(); ++i) {
-            ASSERT_EQ(csyns[i].location, dsyns[i].location);
-        }
-    }
-
-    ASSERT_EQ(1u, c.detectors().size());
-    ASSERT_EQ(1u, d.detectors().size());
-    EXPECT_EQ(c.detectors()[0].threshold, d.detectors()[0].threshold);
-
-    // check clone is independent
-
-    c.add_cable(2, make_segment<cable_segment>(section_kind::dendrite, 0.15, 0.1, 20));
-    EXPECT_NE(c.num_segments(), d.num_segments());
-
-    c.segment(1)->set_compartments(7);
-    EXPECT_NE(c.segment(1)->num_compartments(), d.segment(1)->num_compartments());
-    EXPECT_EQ(c.segment(2)->num_compartments(), d.segment(2)->num_compartments());
+    EXPECT_EQ(con.num_children(3), 0u);
+    EXPECT_EQ(con.num_children(4), 0u);
 }
diff --git a/test/unit/test_cv_policy.cpp b/test/unit/test_cv_policy.cpp
index a7a099682dcf54b38f57f660a5d87c343c590b43..273a413ef8423372ff8a63a9987bc616de4a67db 100644
--- a/test/unit/test_cv_policy.cpp
+++ b/test/unit/test_cv_policy.cpp
@@ -69,7 +69,7 @@ TEST(cv_policy, explicit_policy) {
 
     cv_policy pol = cv_policy_explicit(lset);
     for (auto& m: {m_sph_b6, m_reg_b6, m_mlt_b6}) {
-        cable_cell cell = make_cable_cell(m);
+        cable_cell cell(m);
 
         locset result = pol.cv_boundary_points(cell);
         EXPECT_EQ(thingify(lset, *cell.morphology()), thingify(result, *cell.morphology()));
@@ -89,7 +89,7 @@ TEST(cv_policy, empty_morphology) {
         cv_policy_max_extent(0.234, single_root_cv|interior_forks)
     };
 
-    cable_cell cell = make_cable_cell(m_empty);
+    cable_cell cell(m_empty);
     auto empty_loclist = thingify(ls::nil(), *cell.morphology());
 
     for (auto& pol: policies) {
@@ -112,7 +112,7 @@ TEST(cv_policy, single_root_cv) {
     };
 
     for (auto& morph: {m_sph_b1, m_reg_b1, m_sph_b6, m_reg_b6, m_mlt_b6}) {
-        cable_cell cell = make_cable_cell(morph);
+        cable_cell cell(morph);
 
         for (auto& polpair: policy_pairs) {
             mlocation_list p1 = thingify(polpair.first.cv_boundary_points(cell), *cell.morphology());
@@ -133,7 +133,7 @@ TEST(cv_policy, fixed_per_branch) {
 
     // root branch only
     for (auto& morph: {m_sph_b1, m_reg_b1}) {
-        cable_cell cell = make_cable_cell(morph);
+        cable_cell cell(morph);
         {
             // boundary fork points
             cv_policy pol = cv_policy_fixed_per_branch(4);
@@ -153,7 +153,7 @@ TEST(cv_policy, fixed_per_branch) {
     // spherical root, six branches and multiple top level branches cases:
     // expected points are the same.
     for (auto& morph: {m_sph_b6, m_mlt_b6}) {
-        cable_cell cell = make_cable_cell(morph);
+        cable_cell cell(morph);
 
         {
             // boundary fork points
@@ -184,7 +184,7 @@ TEST(cv_policy, max_extent) {
 
     // root branch only
     for (auto& morph: {m_sph_b1, m_reg_b1}) {
-        cable_cell cell = make_cable_cell(morph);
+        cable_cell cell(morph);
         ASSERT_EQ(1.0, cell.morphology()->branch_length(0));
 
         {
@@ -205,7 +205,7 @@ TEST(cv_policy, max_extent) {
 
     // cell with varying branch lengths; extent not exact fraction.
     {
-        cable_cell cell = make_cable_cell(m_mlt_b6);
+        cable_cell cell(m_mlt_b6);
         ASSERT_EQ(1.0, cell.morphology()->branch_length(0));
         ASSERT_EQ(1.0, cell.morphology()->branch_length(1));
         ASSERT_EQ(2.0, cell.morphology()->branch_length(2));
diff --git a/test/unit/test_domain_decomposition.cpp b/test/unit/test_domain_decomposition.cpp
index 30524dec9ff0aac23ea52dc61f86c753227b50ae..d941780eff13bc2b32584d46aa884e2ccd0bb873 100644
--- a/test/unit/test_domain_decomposition.cpp
+++ b/test/unit/test_domain_decomposition.cpp
@@ -8,6 +8,7 @@
 
 #include "util/span.hpp"
 
+#include "../common_cells.hpp"
 #include "../simple_recipes.hpp"
 
 using namespace arb;
@@ -64,8 +65,7 @@ namespace {
         }
 
         arb::util::unique_any get_cell_description(cell_gid_type) const override {
-            cable_cell c;
-            c.add_soma(20);
+            auto c = arb::make_cell_soma_only(false);
             c.place(mlocation{0,1}, gap_junction_site{});
             return {std::move(c)};
         }
diff --git a/test/unit/test_event_delivery.cpp b/test/unit/test_event_delivery.cpp
index a4d4b632c7edca338f6719c31a6c2181aa3c9713..148289e3c4c1d9cd7bda9d10f8f975bc290a8c04 100644
--- a/test/unit/test_event_delivery.cpp
+++ b/test/unit/test_event_delivery.cpp
@@ -27,11 +27,17 @@ struct test_recipe: public n_cable_cell_recipe {
     explicit test_recipe(int n): n_cable_cell_recipe(n, test_cell()) {}
 
     static cable_cell test_cell() {
-        cable_cell c;
-        c.add_soma(10.)->add_mechanism("pas");
+        sample_tree st;
+        st.append({0,0,0,10,1});
+
+        label_dict d;
+        d.set("soma", arb::reg::tagged(1));
+
+        cable_cell c(st, d, true);
         c.place(mlocation{0, 0.5}, "expsyn");
         c.place(mlocation{0, 0.5}, threshold_detector{-64});
         c.place(mlocation{0, 0.5}, gap_junction_site{});
+
         return c;
     }
 
diff --git a/test/unit/test_fvm_layout.cpp b/test/unit/test_fvm_layout.cpp
index eb16024e46ce3575d087bdbac2b8dffffff6e56d..2808d3ea8fb379791b938989fb8e411d9a67bd8f 100644
--- a/test/unit/test_fvm_layout.cpp
+++ b/test/unit/test_fvm_layout.cpp
@@ -11,6 +11,7 @@
 #include "util/rangeutil.hpp"
 #include "util/span.hpp"
 #include "io/sepval.hpp"
+#include "morph/em_morphology.hpp"
 
 #include "common.hpp"
 #include "unit_test_catalogue.hpp"
@@ -92,40 +93,40 @@ namespace {
         //
         // All dendrite segments with 4 compartments.
 
-        cable_cell c2;
-        segment* s;
+        soma_cell_builder builder(7.);
+        auto b1 = builder.add_branch(0, 200, 0.5,  0.5, 4,  "dend");
+        auto b2 = builder.add_branch(1, 300, 0.4,  0.4, 4,  "dend");
+        auto b3 = builder.add_branch(1, 180, 0.35, 0.35, 4, "dend");
+        auto cell = builder.make_cell();
 
-        c2.default_parameters.axial_resistivity = 90;
+        cell.paint("soma", "hh");
+        cell.paint("dend", "pas");
 
-        s = c2.add_soma(14./2);
-        s->add_mechanism("hh");
+        cable_cell_local_parameter_set p1;
+        p1.membrane_capacitance = 0.017;
+        cable_cell_local_parameter_set p2;
+        p2.membrane_capacitance = 0.013;
+        cable_cell_local_parameter_set p3;
+        p3.membrane_capacitance = 0.018;
 
-        s = c2.add_cable(0, make_segment<cable_segment>(section_kind::dendrite, 1.0/2, 1.0/2, 200));
-        s->parameters.membrane_capacitance = 0.017;
+        using ::arb::reg::branch;
+        cell.paint(branch(b1), p1);
+        cell.paint(branch(b2), p2);
+        cell.paint(branch(b3), p3);
 
-        s = c2.add_cable(1, make_segment<cable_segment>(section_kind::dendrite, 0.8/2, 0.8/2, 300));
-        s->parameters.membrane_capacitance = 0.013;
+        cell.place(mlocation{2,1}, i_clamp{5.,  80., 0.45});
+        cell.place(mlocation{3,1}, i_clamp{40., 10.,-0.2});
 
-        s = c2.add_cable(1, make_segment<cable_segment>(section_kind::dendrite, 0.7/2, 0.7/2, 180));
-        s->parameters.membrane_capacitance = 0.018;
+        cell.default_parameters.axial_resistivity = 90;
 
-        c2.place(mlocation{2,1}, i_clamp{5.,  80., 0.45});
-        c2.place(mlocation{3,1}, i_clamp{40., 10.,-0.2});
-
-        for (auto& seg: c2.segments()) {
-            if (seg->is_dendrite()) {
-                seg->add_mechanism("pas");
-                seg->set_compartments(4);
-            }
-        }
-        cells.push_back(std::move(c2));
+        cells.push_back(std::move(cell));
         return cells;
     }
 
     void check_two_cell_system(std::vector<cable_cell>& cells) {
-        ASSERT_EQ(2u, cells[0].num_segments());
+        ASSERT_EQ(2u, cells[0].num_branches());
         ASSERT_EQ(cells[0].segment(1)->num_compartments(), 4u);
-        ASSERT_EQ(cells[1].num_segments(), 4u);
+        ASSERT_EQ(cells[1].num_branches(), 4u);
         ASSERT_EQ(cells[1].segment(1)->num_compartments(), 4u);
         ASSERT_EQ(cells[1].segment(2)->num_compartments(), 4u);
         ASSERT_EQ(cells[1].segment(3)->num_compartments(), 4u);
@@ -186,7 +187,6 @@ TEST(fvm_layout, topology) {
 
     EXPECT_EQ(ivec({0,0,1,2,3,4,6,6,7,8,9,10,11,12,13,14,11,16,17,18}), D.parent_cv);
 
-
     EXPECT_FALSE(D.segments[0].has_parent());
     EXPECT_EQ(1, D.segments[1].parent_cv);
 
@@ -246,7 +246,7 @@ TEST(fvm_layout, diam_and_area) {
 
     std::vector<double> A;
     for (auto ci: make_span(D.ncell)) {
-        for (auto si: make_span(cells[ci].num_segments())) {
+        for (auto si: make_span(cells[ci].num_branches())) {
             A.push_back(area(cells[ci].segment(si)));
         }
     }
@@ -285,7 +285,6 @@ TEST(fvm_layout, diam_and_area) {
     double c = A[3]/(2*n)*cm1+A[4]/(2*n)*cm2+A[5]/(2*n)*cm3;
     EXPECT_FLOAT_EQ(c, D.cv_capacitance[11]);
 
-    //double cm0 = cells[1].soma()->cm;
     double cm0 = neuron_parameter_defaults.membrane_capacitance.value();
     c = A[2]*cm0;
     EXPECT_FLOAT_EQ(c, D.cv_capacitance[6]);
@@ -295,7 +294,7 @@ TEST(fvm_layout, diam_and_area) {
     // area, and h is the compartment length (given the
     // regular discretization).
 
-    cable_segment* cable = cells[1].segment(2)->as_cable();
+    auto cable = cells[1].segment(2)->as_cable();
     double a = volume(cable)/cable->length();
     EXPECT_FLOAT_EQ(math::pi<double>*0.8*0.8/4, a);
 
@@ -662,7 +661,6 @@ TEST(fvm_layout, synapse_targets) {
     }
 }
 
-
 namespace {
     double wm_impl(double wa, double xa) {
         return wa? xa/wa: 0;
@@ -706,15 +704,12 @@ TEST(fvm_layout, density_norm_area) {
     //
     // Use divided compartment view on segments to compute area contributions.
 
-    std::vector<cable_cell> cells(1);
-    cable_cell& c = cells[0];
-    auto soma = c.add_soma(12.6157/2.0);
-
-    c.add_cable(0, make_segment<cable_segment>(section_kind::dendrite, 0.5, 0.5, 100));
-    c.add_cable(1, make_segment<cable_segment>(section_kind::dendrite, 0.5, 0.1, 200));
-    c.add_cable(1, make_segment<cable_segment>(section_kind::dendrite, 0.4, 0.4, 150));
+    soma_cell_builder builder(12.6157/2.0);
 
-    auto& segs = c.segments();
+    //                 p  len   r1   r2  ncomp tag
+    builder.add_branch(0, 100, 0.5, 0.5,     3, "reg1");
+    builder.add_branch(1, 200, 0.5, 0.1,     3, "reg2");
+    builder.add_branch(1, 150, 0.4, 0.4,     3, "reg3");
 
     double dflt_gkbar = .036;
     double dflt_gl = 0.0003;
@@ -724,26 +719,25 @@ TEST(fvm_layout, density_norm_area) {
     double seg3_gkbar = .0004;
     double seg3_gl = .0004;
 
-    for (int i = 0; i<4; ++i) {
-        segment& seg = *segs[i];
-        seg.set_compartments(3);
-
-        mechanism_desc hh("hh");
-        switch (i) {
-        case 1:
-            hh["gl"] = seg1_gl;
-            break;
-        case 2:
-            hh["gkbar"] = seg2_gkbar;
-            break;
-        case 3:
-            hh["gkbar"] = seg3_gkbar;
-            hh["gl"] = seg3_gl;
-            break;
-        default: ;
-        }
-        seg.add_mechanism(hh);
-    }
+    auto hh_0 = mechanism_desc("hh");
+
+    auto hh_1 = mechanism_desc("hh");
+    hh_1["gl"] = seg1_gl;
+
+    auto hh_2 = mechanism_desc("hh");
+    hh_2["gkbar"] = seg2_gkbar;
+
+    auto hh_3 = mechanism_desc("hh");
+    hh_3["gkbar"] = seg3_gkbar;
+    hh_3["gl"] = seg3_gl;
+
+    auto cell = builder.make_cell();
+    cell.paint("soma", std::move(hh_0));
+    cell.paint("reg1", std::move(hh_1));
+    cell.paint("reg2", std::move(hh_2));
+    cell.paint("reg3", std::move(hh_3));
+
+    std::vector<cable_cell> cells{std::move(cell)};
 
     int ncv = 11; //ncomp + 1
     std::vector<double> expected_gkbar(ncv, dflt_gkbar);
@@ -752,7 +746,8 @@ TEST(fvm_layout, density_norm_area) {
     auto div_by_ends = [](const cable_segment* cable) {
         return div_compartment_by_ends(cable->num_compartments(), cable->radii(), cable->lengths());
     };
-    double soma_area = area(soma);
+    auto& segs = cells[0].segments();
+    double soma_area = area(segs[0].get());
     auto seg1_divs = div_by_ends(segs[1]->as_cable());
     auto seg2_divs = div_by_ends(segs[2]->as_cable());
     auto seg3_divs = div_by_ends(segs[3]->as_cable());
@@ -819,11 +814,9 @@ TEST(fvm_layout, density_norm_area) {
 }
 
 TEST(fvm_layout, valence_verify) {
-    std::vector<cable_cell> cells(1);
-    cable_cell& c = cells[0];
-    auto soma = c.add_soma(6);
-
-    soma->add_mechanism("test_cl_valence");
+    auto cell = soma_cell_builder(6).make_cell();
+    cell.paint("soma", "test_cl_valence");
+    std::vector<cable_cell> cells{std::move(cell)};
 
     cable_cell_global_properties gprop;
     gprop.default_parameters = neuron_parameter_defaults;
@@ -863,29 +856,27 @@ TEST(fvm_layout, ion_weights) {
     //
     // Geometry:
     //   soma 0: radius 5 µm
-    //   dend 1: 100 µm long, 1 µm diameter cynlinder
-    //   dend 2: 200 µm long, 1 µm diameter cynlinder
-    //   dend 3: 100 µm long, 1 µm diameter cynlinder
+    //   dend 1: 100 µm long, 1 µm diameter cylinder, tag 2
+    //   dend 2: 200 µm long, 1 µm diameter cylinder, tag 3
+    //   dend 3: 100 µm long, 1 µm diameter cylinder, tag 4
     //
     // The radius of the soma is chosen such that the surface area of soma is
     // the same as a 100µm dendrite, which makes it easier to describe the
     // expected weights.
 
-    auto construct_cell = [](cable_cell& c) {
-        c.add_soma(5);
-
-        c.add_cable(0, make_segment<cable_segment>(section_kind::dendrite, 0.5, 0.5, 100));
-        c.add_cable(1, make_segment<cable_segment>(section_kind::dendrite, 0.5, 0.5, 200));
-        c.add_cable(1, make_segment<cable_segment>(section_kind::dendrite, 0.5, 0.5, 100));
-
-        for (auto& s: c.segments()) s->set_compartments(1);
+    auto construct_cell = []() {
+        soma_cell_builder builder(5);
+        builder.add_branch(0, 100, 0.5, 0.5, 1, "dend");
+        builder.add_branch(1, 200, 0.5, 0.5, 1, "dend");
+        builder.add_branch(1, 100, 0.5, 0.5, 1, "dend");
+        return builder.make_cell();
     };
 
     using uvec = std::vector<fvm_size_type>;
     using ivec = std::vector<fvm_index_type>;
     using fvec = std::vector<fvm_value_type>;
 
-    uvec mech_segs[] = {
+    uvec mech_branches[] = {
         {0}, {0,2}, {2, 3}, {0, 1, 2, 3}, {3}
     };
 
@@ -909,16 +900,16 @@ TEST(fvm_layout, ion_weights) {
         }
     }
 
-    for (auto run: count_along(mech_segs)) {
+    for (auto run: count_along(mech_branches)) {
         SCOPED_TRACE("run "+std::to_string(run));
-        std::vector<cable_cell> cells(1);
-        cable_cell& c = cells[0];
-        construct_cell(c);
+        auto c = construct_cell();
 
-        for (auto i: mech_segs[run]) {
-            c.segments()[i]->add_mechanism("test_ca");
+        for (auto i: mech_branches[run]) {
+            c.paint(reg::branch(i), "test_ca");
         }
 
+        std::vector<cable_cell> cells{std::move(c)};
+
         fvm_discretization D = fvm_discretize(cells, gprop.default_parameters);
         fvm_mechanism_data M = fvm_build_mechanism_data(gprop, cells, D);
 
@@ -943,24 +934,18 @@ TEST(fvm_layout, revpot) {
     //     * Reversal potential mechanisms are only extended where there exists another
     //       mechanism that reads them.
 
-    auto construct_cell = [](cable_cell& c) {
-        c.add_soma(5);
-
-        c.add_cable(0, make_segment<cable_segment>(section_kind::dendrite, 0.5, 0.5, 100));
-        c.add_cable(1, make_segment<cable_segment>(section_kind::dendrite, 0.5, 0.5, 200));
-        c.add_cable(1, make_segment<cable_segment>(section_kind::dendrite, 0.5, 0.5, 100));
-
-        for (auto& s: c.segments()) s->set_compartments(1);
-
-        // Read ea everywhere, ec only on soma.
-        for (auto& s: c.segments()) s->add_mechanism("read_eX/a");
-        c.soma()->add_mechanism("read_eX/c");
-    };
-
     mechanism_catalogue testcat = make_unit_test_catalogue();
 
-    std::vector<cable_cell> cells(2);
-    for (auto& c: cells) construct_cell(c);
+    soma_cell_builder builder(5);
+    builder.add_branch(0, 100, 0.5, 0.5, 1, "dend");
+    builder.add_branch(1, 200, 0.5, 0.5, 1, "dend");
+    builder.add_branch(1, 100, 0.5, 0.5, 1, "dend");
+    auto cell = builder.make_cell();
+    cell.paint("soma", "read_eX/c");
+    cell.paint("soma", "read_eX/a");
+    cell.paint("dend", "read_eX/a");
+
+    std::vector<cable_cell> cells{cell, cell};
 
     cable_cell_global_properties gprop;
     gprop.default_parameters = neuron_parameter_defaults;
diff --git a/test/unit/test_fvm_lowered.cpp b/test/unit/test_fvm_lowered.cpp
index a82132f497579e916fee647bd3a7fab7c84b6798..191249958991bbd5eb60680beeca7d8086e1fa20 100644
--- a/test/unit/test_fvm_lowered.cpp
+++ b/test/unit/test_fvm_lowered.cpp
@@ -92,8 +92,7 @@ public:
     }
 
     arb::util::unique_any get_cell_description(cell_gid_type gid) const override {
-        cable_cell c;
-        c.add_soma(20);
+        cable_cell c = soma_cell_builder(20).make_cell();
         c.place(mlocation{0, 1}, gap_junction_site{});
         return {std::move(c)};
     }
@@ -138,9 +137,7 @@ public:
     }
 
     arb::util::unique_any get_cell_description(cell_gid_type) const override {
-        cable_cell c;
-        c.add_soma(20);
-        return {std::move(c)};
+        return soma_cell_builder(20).make_cell();
     }
 
     cell_kind get_cell_kind(cell_gid_type gid) const override {
@@ -160,8 +157,7 @@ public:
     }
 
     arb::util::unique_any get_cell_description(cell_gid_type) const override {
-        cable_cell c;
-        c.add_soma(20);
+        cable_cell c = soma_cell_builder(20).make_cell();
         c.place(mlocation{0,1}, gap_junction_site{});
         return {std::move(c)};
     }
@@ -213,10 +209,9 @@ TEST(fvm_lowered, matrix_init)
     auto ispos = [](auto v) { return v>0; };
     auto isneg = [](auto v) { return v<0; };
 
-    cable_cell cell = make_cell_ball_and_stick();
-
-    ASSERT_EQ(2u, cell.num_segments());
-    cell.segment(1)->set_compartments(10);
+    soma_cell_builder builder(12.6157/2.0);
+    builder.add_branch(0, 200, 1.0/2, 1.0/2, 10, "dend"); // 10 compartments
+    cable_cell cell = builder.make_cell();
 
     std::vector<target_handle> targets;
     std::vector<fvm_index_type> cell_to_intdom;
@@ -253,8 +248,8 @@ TEST(fvm_lowered, target_handles) {
         make_cell_ball_and_3stick()
     };
 
-    EXPECT_EQ(cells[0].num_segments(), 2u);
-    EXPECT_EQ(cells[1].num_segments(), 4u);
+    EXPECT_EQ(cells[0].num_branches(), 2u);
+    EXPECT_EQ(cells[1].num_branches(), 4u);
 
     // (in increasing target order)
     cells[0].place(mlocation{1, 0.4}, "expsyn");
@@ -397,30 +392,26 @@ TEST(fvm_lowered, derived_mechs) {
     //
     // 3. Cell with both test_kin1 and custom_kin1.
 
-    std::vector<cable_cell> cells(3);
+    std::vector<cable_cell> cells;
+    cells.reserve(3);
     for (int i = 0; i<3; ++i) {
-        cable_cell& c = cells[i];
-        c.add_soma(6.0);
-        c.add_cable(0, make_segment<cable_segment>(section_kind::dendrite, 0.5, 0.5, 100));
-
-        c.segment(1)->set_compartments(4);
-        for (auto& seg: c.segments()) {
-            if (!seg->is_soma()) {
-                seg->as_cable()->set_compartments(4);
-            }
-            switch (i) {
+        soma_cell_builder builder(6);
+        builder.add_branch(0, 100, 0.5, 0.5, 4, "dend");
+        auto cell = builder.make_cell();
+
+        switch (i) {
             case 0:
-                seg->add_mechanism("test_kin1");
+                cell.paint(reg::all(), "test_kin1");
                 break;
             case 1:
-                seg->add_mechanism("custom_kin1");
+                cell.paint(reg::all(), "custom_kin1");
                 break;
             case 2:
-                seg->add_mechanism("test_kin1");
-                seg->add_mechanism("custom_kin1");
+                cell.paint(reg::all(), "test_kin1");
+                cell.paint(reg::all(), "custom_kin1");
                 break;
-            }
         }
+        cells.push_back(std::move(cell));
     }
 
     cable1d_recipe rec(cells);
@@ -506,11 +497,10 @@ TEST(fvm_lowered, read_valence) {
     {
         std::vector<cable_cell> cells(1);
 
-        cable_cell& c = cells[0];
-        auto soma = c.add_soma(6.0);
-        soma->add_mechanism("test_ca_read_valence");
-
-        cable1d_recipe rec(cells);
+        soma_cell_builder builder(6);
+        auto cell = builder.make_cell();
+        cell.paint("soma", "test_ca_read_valence");
+        cable1d_recipe rec({std::move(cell)});
         rec.catalogue() = make_unit_test_catalogue();
 
         fvm_cell fvcell(context);
@@ -529,13 +519,11 @@ TEST(fvm_lowered, read_valence) {
 
     {
         // Check ion renaming.
-        std::vector<cable_cell> cells(1);
-
-        cable_cell& c = cells[0];
-        auto soma = c.add_soma(6.0);
-        soma->add_mechanism("cr_read_valence");
-
-        cable1d_recipe rec(cells);
+        soma_cell_builder builder(6);
+        auto cell = builder.make_cell();
+        cell.paint("soma", "cr_read_valence");
+        cable1d_recipe rec({std::move(cell)});
+        rec.catalogue() = make_unit_test_catalogue();
         rec.catalogue() = make_unit_test_catalogue();
 
         rec.catalogue().derive("na_read_valence", "test_ca_read_valence", {}, {{"ca", "na"}});
@@ -557,13 +545,13 @@ TEST(fvm_lowered, read_valence) {
 // Test correct scaling of ionic currents in reading and writing
 
 TEST(fvm_lowered, ionic_currents) {
-    cable_cell c;
-    auto soma = c.add_soma(6.0);
+    soma_cell_builder b(6);
 
     // Mechanism parameter is in NMODL units, i.e. mA/cm².
 
     const double jca = 1.5;
-    soma->add_mechanism(mechanism_desc("fixed_ica_current").set("ica_density", jca));
+    mechanism_desc m1("fixed_ica_current");
+    m1["ica_density"] = jca;
 
     // Mechanism models a well-mixed fixed-depth volume without replenishment,
     // giving a linear response to ica over time.
@@ -573,9 +561,14 @@ TEST(fvm_lowered, ionic_currents) {
     // with NMODL units: cai' [mM/ms]; ica [mA/cm²], giving coeff in [mol/cm/C].
 
     const double coeff = 0.5;
-    soma->add_mechanism(mechanism_desc("linear_ca_conc").set("coeff", coeff));
+    mechanism_desc m2("linear_ca_conc");
+    m2["coeff"] = coeff;
 
-    cable1d_recipe rec(c);
+    auto c = b.make_cell();
+    c.paint("soma", m1);
+    c.paint("soma", m2);
+
+    cable1d_recipe rec(std::move(c));
     rec.catalogue() = make_unit_test_catalogue();
 
     execution_context context;
@@ -604,10 +597,9 @@ TEST(fvm_lowered, ionic_currents) {
 // Test correct scaling of an ionic current updated via a point mechanism
 
 TEST(fvm_lowered, point_ionic_current) {
-    cable_cell c;
-
     double r = 6.0; // [µm]
-    c.add_soma(6.0);
+    soma_cell_builder b(r);
+    cable_cell c = b.make_cell();
 
     double soma_area_m2 = 4*math::pi<double>*r*r*1e-12; // [m²]
 
@@ -673,14 +665,12 @@ TEST(fvm_lowered, weighted_write_ion) {
 
     execution_context context;
 
-    cable_cell c;
-    c.add_soma(5);
+    soma_cell_builder b(5);
+    b.add_branch(0, 100, 0.5, 0.5, 1, "dend");
+    b.add_branch(1, 200, 0.5, 0.5, 1, "dend");
+    b.add_branch(1, 100, 0.5, 0.5, 1, "dend");
 
-    c.add_cable(0, make_segment<cable_segment>(section_kind::dendrite, 0.5, 0.5, 100));
-    c.add_cable(1, make_segment<cable_segment>(section_kind::dendrite, 0.5, 0.5, 200));
-    c.add_cable(1, make_segment<cable_segment>(section_kind::dendrite, 0.5, 0.5, 100));
-
-    for (auto& s: c.segments()) s->set_compartments(1);
+    cable_cell c = b.make_cell();
 
     const double con_int = 80;
     const double con_ext = 120;
@@ -768,18 +758,21 @@ TEST(fvm_lowered, gj_coords_simple) {
 
     gap_recipe rec;
     std::vector<cable_cell> cells;
-    cable_cell c, d;
-    c.add_soma(2.1);
-    c.add_cable(0, make_segment<cable_segment>(section_kind::dendrite, 0.3, 0.2, 10));
-    c.segment(1)->set_compartments(5);
-    c.place(mlocation{1, 0.8}, gap_junction_site{});
-    cells.push_back(std::move(c));
-
-    d.add_soma(2.4);
-    d.add_cable(0, make_segment<cable_segment>(section_kind::dendrite, 0.3, 0.2, 10));
-    d.segment(1)->set_compartments(2);
-    d.place(mlocation{1, 1}, gap_junction_site{});
-    cells.push_back(std::move(d));
+    {
+        soma_cell_builder b(2.1);
+        b.add_branch(0, 10, 0.3, 0.2, 5, "dend");
+        auto c = b.make_cell();
+        c.place(mlocation{1, 0.8}, gap_junction_site{});
+        cells.push_back(std::move(c));
+    }
+
+    {
+        soma_cell_builder b(2.4);
+        b.add_branch(0, 10, 0.3, 0.2, 2, "dend");
+        auto c = b.make_cell();
+        c.place(mlocation{1, 1}, gap_junction_site{});
+        cells.push_back(std::move(c));
+    }
 
     fvm_discretization D = fvm_discretize(cells, neuron_parameter_defaults);
 
@@ -840,56 +833,44 @@ TEST(fvm_lowered, gj_coords_complex) {
     execution_context context;
     fvm_cell fvcell(context);
 
-    gap_recipe rec;
-    cable_cell c0, c1, c2;
-    std::vector<cable_cell> cells;
-
-    // Make 3 cells
-    c0.add_soma(2.1);
-    c0.add_cable(0, make_segment<cable_segment>(section_kind::dendrite, 0.3, 0.2, 8));
-    c0.segment(1)->set_compartments(4);
-
-    c1.add_soma(1.4);
-    c1.add_cable(0, make_segment<cable_segment>(section_kind::dendrite, 0.3, 0.5, 12));
-    c1.segment(1)->set_compartments(6);
-    c1.add_cable(1, make_segment<cable_segment>(section_kind::dendrite, 0.3, 0.2, 9));
-    c1.segment(2)->set_compartments(3);
-    c1.add_cable(1, make_segment<cable_segment>(section_kind::dendrite, 0.2, 0.2, 15));
-    c1.segment(3)->set_compartments(5);
-
-    c2.add_soma(2.9);
-    c2.add_cable(0, make_segment<cable_segment>(section_kind::dendrite, 0.3, 0.5, 4));
-    c2.segment(1)->set_compartments(2);
-    c2.add_cable(1, make_segment<cable_segment>(section_kind::dendrite, 0.4, 0.2, 6));
-    c2.segment(2)->set_compartments(2);
-    c2.add_cable(1, make_segment<cable_segment>(section_kind::dendrite, 0.1, 0.2, 8));
-    c2.segment(3)->set_compartments(2);
-    c2.add_cable(2, make_segment<cable_segment>(section_kind::dendrite, 0.2, 0.2, 4));
-    c2.segment(4)->set_compartments(2);
-    c2.add_cable(2, make_segment<cable_segment>(section_kind::dendrite, 0.2, 0.2, 4));
-    c2.segment(5)->set_compartments(2);
-
     // Add 5 gap junctions
+    soma_cell_builder b0(2.1);
+    b0.add_branch(0, 8, 0.3, 0.2, 4, "dend");
+
+    auto c0 = b0.make_cell();
     c0.place(mlocation{1, 1}, gap_junction_site{});
     c0.place(mlocation{1, 0.5}, gap_junction_site{});
 
+    soma_cell_builder b1(1.4);
+    b1.add_branch(0, 12, 0.3, 0.5, 6, "dend");
+    b1.add_branch(1,  9, 0.3, 0.2, 3, "dend");
+    b1.add_branch(1,  5, 0.2, 0.2, 5, "dend");
+
+    auto c1 = b1.make_cell();
     c1.place(mlocation{2, 1}, gap_junction_site{});
     c1.place(mlocation{1, 1}, gap_junction_site{});
     c1.place(mlocation{1, 0.45}, gap_junction_site{});
     c1.place(mlocation{1, 0.1}, gap_junction_site{});
 
+    soma_cell_builder b2(2.9);
+    b2.add_branch(0, 4, 0.3, 0.5, 2, "dend");
+    b2.add_branch(1, 6, 0.4, 0.2, 2, "dend");
+    b2.add_branch(1, 8, 0.1, 0.2, 2, "dend");
+    b2.add_branch(2, 4, 0.2, 0.2, 2, "dend");
+    b2.add_branch(2, 4, 0.2, 0.2, 2, "dend");
+
+    auto c2 = b2.make_cell();
     c2.place(mlocation{1, 0.5}, gap_junction_site{});
     c2.place(mlocation{4, 1}, gap_junction_site{});
     c2.place(mlocation{2, 1}, gap_junction_site{});
 
-    cells.push_back(std::move(c0));
-    cells.push_back(std::move(c1));
-    cells.push_back(std::move(c2));
+    std::vector<cable_cell> cells{std::move(c0), std::move(c1), std::move(c2)};
 
     std::vector<fvm_index_type> cell_to_intdom;
 
     std::vector<cell_gid_type> gids = {0, 1, 2};
 
+    gap_recipe rec;
     fvcell.fvm_intdom(rec, gids, cell_to_intdom);
     fvm_discretization D = fvm_discretize(cells, neuron_parameter_defaults);
 
@@ -955,8 +936,7 @@ TEST(fvm_lowered, cell_group_gj) {
 
     // Make 20 cells
     for (unsigned i = 0; i < 20; i++) {
-        cable_cell c;
-        c.add_soma(2.1);
+        cable_cell c = soma_cell_builder(2.1).make_cell();
         if (i % 2 == 0) {
             c.place(mlocation{0, 1}, gap_junction_site{});
         }
diff --git a/test/unit/test_mc_cell_group.cpp b/test/unit/test_mc_cell_group.cpp
index 25415273f1d19c78a1fc0ec5710ff3eeb94f0c87..1be195302555b8cf917c8bc1c78031c92f489d42 100644
--- a/test/unit/test_mc_cell_group.cpp
+++ b/test/unit/test_mc_cell_group.cpp
@@ -21,11 +21,13 @@ namespace {
     }
 
     cable_cell make_cell() {
-        auto c = make_cell_ball_and_stick();
-
+        soma_cell_builder builder(12.6157/2.0);
+        builder.add_branch(0, 200, 0.5, 0.5, 101, "dend");
+        cable_cell c = builder.make_cell();
+        c.paint("soma", "hh");
+        c.paint("dend", "pas");
+        c.place(mlocation{1,1}, i_clamp{5, 80, 0.3});
         c.place(mlocation{0, 0}, threshold_detector{0});
-        c.segment(1)->set_compartments(101);
-
         return c;
     }
 }
@@ -36,6 +38,7 @@ ACCESS_BIND(
     &mc_cell_group::spike_sources_)
 
 TEST(mc_cell_group, get_kind) {
+    auto x = make_cell();
     mc_cell_group group{{0}, cable1d_recipe(make_cell()), lowered_cell()};
 
     EXPECT_EQ(cell_kind::cable, group.get_cell_kind());
diff --git a/test/unit/test_mc_cell_group_gpu.cpp b/test/unit/test_mc_cell_group_gpu.cpp
index 05b3d68193969ea63461e956fc3be763948b26cc..1053b0b854b07eb9c51e162f26866efb9c503607 100644
--- a/test/unit/test_mc_cell_group_gpu.cpp
+++ b/test/unit/test_mc_cell_group_gpu.cpp
@@ -19,11 +19,13 @@ namespace {
     }
 
     cable_cell make_cell() {
-        auto c = make_cell_ball_and_stick();
-
+        soma_cell_builder builder(12.6157/2.0);
+        builder.add_branch(0, 200, 0.5, 0.5, 101, "dend");
+        cable_cell c = builder.make_cell();
+        c.paint("soma", "hh");
+        c.paint("dend", "pas");
+        c.place(mlocation{1,1}, i_clamp{5, 80, 0.3});
         c.place(mlocation{0, 0}, threshold_detector{0});
-        c.segment(1)->set_compartments(101);
-
         return c;
     }
 }
diff --git a/test/unit/test_morphology.cpp b/test/unit/test_morphology.cpp
index fddbd74434a83d13bf1981d9a664170c36b33f03..eb34f28939ec7574e02bbd9fa51d3cb4f6cf3c49 100644
--- a/test/unit/test_morphology.cpp
+++ b/test/unit/test_morphology.cpp
@@ -570,7 +570,7 @@ TEST(morphology, swc) {
     EXPECT_EQ(31u, m.num_branches());
 
     // Confirm that converting to a cable_cell generates the same number of branches.
-    auto c = arb::make_cable_cell(m, {}, false);
-    EXPECT_EQ(31u, c.num_segments());
+    auto c = arb::cable_cell(m, {}, false);
+    EXPECT_EQ(31u, c.num_branches());
 }
 
diff --git a/test/unit/test_synapses.cpp b/test/unit/test_synapses.cpp
index 5101732d2edd991386c2ac9784191c99228d407f..0c10474c08d58fe8d2bd7b3af51c3f6f800fc52f 100644
--- a/test/unit/test_synapses.cpp
+++ b/test/unit/test_synapses.cpp
@@ -14,6 +14,7 @@
 #include "util/maputil.hpp"
 #include "util/range.hpp"
 
+#include "../common_cells.hpp"
 #include "common.hpp"
 #include "mech_private_field_access.hpp"
 
@@ -32,11 +33,7 @@ ACCESS_BIND(value_type* multicore::mechanism::*, vec_i_ptr, &multicore::mechanis
 TEST(synapses, add_to_cell) {
     using namespace arb;
 
-    cable_cell cell;
-
-    // Soma with diameter 12.6157 um and HH channel
-    auto soma = cell.add_soma(12.6157/2.0);
-    soma->add_mechanism("hh");
+    auto cell = make_cell_soma_only(false);
 
     cell.place(mlocation{0, 0.1}, "expsyn");
     cell.place(mlocation{0, 0.2}, "exp2syn");
@@ -58,7 +55,7 @@ TEST(synapses, add_to_cell) {
     EXPECT_EQ(syns[2].mechanism.name(), "expsyn");
 
     // adding a synapse to an invalid branch location should throw.
-    EXPECT_THROW(cell.place(mlocation{1, 0.3}, "expsyn"), arb::cable_cell_error);
+    EXPECT_THROW(cell.place(mlocation{1, 0.3}, "expsyn"), std::runtime_error);
 }
 
 template <typename Seq>
diff --git a/test/validation/CMakeLists.txt b/test/validation/CMakeLists.txt
deleted file mode 100644
index 7d885e07b8964a9f74810f6e5db398f4d871ad62..0000000000000000000000000000000000000000
--- a/test/validation/CMakeLists.txt
+++ /dev/null
@@ -1,25 +0,0 @@
-set(validation_sources
-    # unit tests
-    validate_ball_and_stick.cpp
-    validate_soma.cpp
-    validate_kinetic.cpp
-    validate_synapses.cpp
-
-    # support code
-    validation_data.cpp
-    trace_analysis.cpp
-
-    # unit test driver
-    validate.cpp
-)
-
-add_executable(validate EXCLUDE_FROM_ALL ${validation_sources})
-add_dependencies(tests validate)
-
-target_compile_options(validate PRIVATE ${ARB_CXXOPT_ARCH})
-target_compile_definitions(validate PRIVATE "ARB_DATADIR=\"${ARB_VALIDATION_DATA_DIR}\"")
-target_link_libraries(validate PRIVATE gtest arbor arbor-sup ext-json)
-
-if(ARB_BUILD_VALIDATION_DATA)
-    add_dependencies(validate validation_data)
-endif()
diff --git a/test/validation/convergence_test.hpp b/test/validation/convergence_test.hpp
deleted file mode 100644
index 189615bbc551637e1a362e9f0fdb803ee3e52967..0000000000000000000000000000000000000000
--- a/test/validation/convergence_test.hpp
+++ /dev/null
@@ -1,176 +0,0 @@
-#pragma once
-
-#include <iterator>
-#include <vector>
-
-#include <nlohmann/json.hpp>
-
-#include <arbor/sampling.hpp>
-#include <arbor/simple_sampler.hpp>
-#include <arbor/simulation.hpp>
-#include <arbor/schedule.hpp>
-#include <sup/path.hpp>
-
-#include "../gtest.h"
-
-#include "trace_analysis.hpp"
-#include "validation_data.hpp"
-
-namespace arb {
-
-struct probe_label {
-    const char* label;
-    cell_member_type probe_id;
-};
-
-/* Common functionality for testing convergence towards
- * a reference data set as some parameter of the model
- * is changed.
- *
- * Type parameter Param is the type of the parameter that
- * is changed between runs.
- */
-
-template <typename Param>
-class convergence_test_runner {
-private:
-    std::string param_name_;
-    bool run_validation_;
-    nlohmann::json meta_;
-    std::vector<probe_label> probe_labels_;
-    std::map<std::string, trace_data<double>> ref_data_;
-    std::vector<conv_entry<Param>> conv_tbl_;
-
-public:
-    template <typename ProbeLabelSeq>
-    convergence_test_runner(
-        const std::string& param_name,
-        const ProbeLabelSeq& probe_labels,
-        const nlohmann::json& meta
-    ):
-        param_name_(param_name),
-        run_validation_(false),
-        meta_(meta)
-    {
-        using std::begin;
-        using std::end;
-
-        probe_labels_.assign(begin(probe_labels), end(probe_labels));
-    }
-
-    // Allow free access to JSON meta data attached to saved traces.
-    nlohmann::json& metadata() { return meta_; }
-
-    void load_reference_data(const sup::path& ref_path) {
-        run_validation_ = false;
-        try {
-            ref_data_ = g_trace_io.load_traces(ref_path);
-
-            run_validation_ = true;
-            for (const auto& pl: probe_labels_) {
-                if (!(ref_data_.count(pl.label)>0)) {
-                    run_validation_ = false;
-                    break;
-                }
-            }
-
-            EXPECT_TRUE(run_validation_);
-        }
-        catch (std::runtime_error&) {
-            ADD_FAILURE() << "failure loading reference data: " << ref_path;
-        }
-    }
-
-    void run(simulation& sim, Param p, float sample_dt, float t_end, float dt, const std::vector<float>& excl) {
-        struct sampler_state {
-            sampler_association_handle h; // Keep these for clean up at end.
-            const char* label;
-            trace_data<double> trace;
-        };
-
-        // Attach new samplers to labelled probe ids.
-        std::vector<sampler_state> samplers;
-        samplers.reserve(probe_labels_.size());
-
-        for (auto& pl: probe_labels_) {
-            samplers.push_back({});
-            auto& entry = samplers.back();
-
-            entry.label = pl.label;
-            entry.h = sim.add_sampler(one_probe(pl.probe_id), regular_schedule(sample_dt), simple_sampler<double>(entry.trace));
-        }
-
-        sim.run(t_end, dt);
-
-        for (auto& entry: samplers) {
-            std::string label = entry.label;
-            const auto& trace = entry.trace;
-
-            // Save trace.
-            nlohmann::json trace_meta(meta_);
-            trace_meta[param_name_] = p;
-
-            g_trace_io.save_trace(label, trace, trace_meta);
-
-            // Compute metreics.
-            if (run_validation_) {
-                double linf = linf_distance(ref_data_[label], trace, excl);
-                auto pd = peak_delta(trace, ref_data_[label]);
-
-                conv_tbl_.push_back({label, p, linf, pd});
-            }
-        }
-
-        // Remove added samplers.
-        for (const auto& entry: samplers) {
-            sim.remove_sampler(entry.h);
-        }
-    }
-
-    void report() {
-        if (run_validation_ && g_trace_io.verbose()) {
-            // reorder to group by id
-            std::stable_sort(conv_tbl_.begin(), conv_tbl_.end(),
-                [](const auto& a, const auto& b) { return a.id<b.id; });
-
-            report_conv_table(std::cout, conv_tbl_, param_name_);
-        }
-    }
-
-    void assert_all_convergence() const {
-        std::vector<conv_entry<Param>> with_label;
-
-        for (const auto& pl: probe_labels_) {
-            SCOPED_TRACE(pl.label);
-
-            with_label.clear();
-            for (const auto& e: conv_tbl_) {
-                if (e.id==pl.label) {
-                    with_label.push_back(e);
-                }
-            }
-
-            assert_convergence(with_label);
-        }
-    }
-};
-
-/*
- * Extract time points to exclude from current stimulus end-points.
- */
-
-inline std::vector<float> stimulus_ends(const cable_cell& c) {
-    std::vector<float> ts;
-
-    for (const auto& stimulus: c.stimuli()) {
-        float t0 = stimulus.clamp.delay;
-        float t1 = t0+stimulus.clamp.duration;
-        ts.push_back(t0);
-        ts.push_back(t1);
-    }
-
-    std::sort(ts.begin(), ts.end());
-    return ts;
-}
-
-} // namespace arb
diff --git a/test/validation/interpolate.hpp b/test/validation/interpolate.hpp
deleted file mode 100644
index bb6ec2423099e39737c175ed945cb42452507431..0000000000000000000000000000000000000000
--- a/test/validation/interpolate.hpp
+++ /dev/null
@@ -1,52 +0,0 @@
-#pragma once
-
-#include <cmath>
-
-#include <arbor/util/compat.hpp>
-
-template <typename T, typename U>
-inline T lerp(T a, T b, U u) {
-    return compat::fma(T(u), b, compat::fma(T(-u), a, a));
-}
-
-// Piece-wise linear interpolation across a sequence of points (u_i, x_i),
-// monotonically increasing in u.
-// 
-// Parameters get_u and get_x provide the accessors for the point sequence;
-// consider moving to structured bindings in C++17 instead.
-
-template <typename U, typename Seq, typename GetU, typename GetX>
-auto pw_linear_interpolate(U u, const Seq& seq, GetU get_u, GetX get_x) {
-    using std::begin;
-    using std::end;
-    using value_type = decltype(get_x(*begin(seq)));
-
-    auto i = begin(seq);
-    auto e = end(seq);
-
-    if (i==e) {
-        return value_type(NAN);
-    }
-
-    auto u0 = get_u(*i);
-    auto x0 = get_x(*i);
-
-    if (u<u0) {
-        return x0;
-    }
-
-    while (++i!=e) {
-        auto u1 = get_u(*i);
-        auto x1 = get_x(*i);
-
-        if (u<u1) {
-            return lerp(x0, x1, (u-u0)/(u1-u0));
-        }
-
-        u0 = u1;
-        x0 = x1;
-    }
-
-    return x0;
-}
-
diff --git a/test/validation/make_image.sh b/test/validation/make_image.sh
deleted file mode 100644
index c3b4a91096bba267810067fceebfe73c8ee271d8..0000000000000000000000000000000000000000
--- a/test/validation/make_image.sh
+++ /dev/null
@@ -1,5 +0,0 @@
-dot cell.dot -Tpdf -o cell.pdf
-# uses display from imagemagik
-display cell.pdf
-# or just call open on os x
-#open cell.pdf
diff --git a/test/validation/plot.py b/test/validation/plot.py
deleted file mode 100644
index 754eb6f2cfcf336215d5b1c1ba440138833ad3c8..0000000000000000000000000000000000000000
--- a/test/validation/plot.py
+++ /dev/null
@@ -1,20 +0,0 @@
-from matplotlib import pyplot
-import numpy as np
-
-raw = np.fromfile("den_syn.txt", sep=" ")
-n = raw.size/3
-data = raw.reshape(n,3)
-
-t    = data[:, 0]
-soma = data[:, 1]
-dend = data[:, 2]
-
-pyplot.plot(t, soma)
-pyplot.plot(t, dend)
-
-pyplot.xlabel('time (ms)')
-pyplot.ylabel('mV')
-pyplot.xlim([t[0], t[n-1]])
-pyplot.grid()
-pyplot.show()
-
diff --git a/test/validation/trace_analysis.cpp b/test/validation/trace_analysis.cpp
deleted file mode 100644
index ccf71a4284842d46bf70cf553800c4390b389bc2..0000000000000000000000000000000000000000
--- a/test/validation/trace_analysis.cpp
+++ /dev/null
@@ -1,125 +0,0 @@
-#include <algorithm>
-#include <fstream>
-#include <string>
-
-#include <nlohmann/json.hpp>
-
-#include "../gtest.h"
-
-#include <arbor/math.hpp>
-#include <arbor/simple_sampler.hpp>
-#include <arbor/util/optional.hpp>
-
-#include "interpolate.hpp"
-#include "trace_analysis.hpp"
-
-namespace arb {
-
-struct trace_interpolant {
-    trace_interpolant(const trace_data<double>& trace): trace_(trace) {}
-
-    double operator()(float t) const {
-        return pw_linear_interpolate(t, trace_,
-            [](auto& entry) { return entry.t; },
-            [](auto& entry) { return entry.v; });
-    }
-
-    const trace_data<double>& trace_;
-};
-
-double linf_distance(const trace_data<double>& u, const trace_data<double>& r) {
-    trace_interpolant f{r};
-
-    double linf = 0;
-    for (auto entry: u) {
-        linf = std::max(linf, std::abs(entry.v-f(entry.t)));
-    }
-
-    return linf;
-}
-
-// Compute linf distance as above, but excluding sample points that lie
-// near points in `excl`.
-//
-// `excl` contains the times to exclude, in ascending order.
-
-double linf_distance(const trace_data<double>& u, const trace_data<double>& ref, const std::vector<float>& excl) {
-    trace_interpolant f{ref};
-
-    trace_data<double> reduced;
-    unsigned nexcl = excl.size();
-    unsigned ei = 0;
-
-    unsigned nu = u.size();
-    unsigned ui = 0;
-
-    while (ei<nexcl && ui<nu) {
-        float t = excl[ei++];
-
-        unsigned uj = ui;
-        while (uj<nu && u[uj].t<t) ++uj;
-
-        // include points up to and including uj-2, and then proceed from point uj+1,
-        // excluding the two points closest to the discontinuity.
-
-        for (unsigned k = ui; k+1<uj; ++k) {
-            reduced.push_back(u[k]);
-        }
-        ui = uj+1;
-    }
-
-    for (auto k = ui; k<nu; ++k) {
-        reduced.push_back(u[k]);
-    }
-
-    return linf_distance(reduced, ref);
-}
-
-std::vector<trace_peak> local_maxima(const trace_data<double>& u) {
-    std::vector<trace_peak> peaks;
-    if (u.size()<2) return peaks;
-
-    int s_prev = math::signum(u[1].v-u[0].v);
-    std::size_t i_start = 0;
-
-    for (std::size_t i = 2; i<u.size()-1; ++i) {
-        int s = math::signum(u[i].v-u[i-1].v);
-        if (s_prev==1 && s==-1) {
-            // found peak between i_start and i,
-            // observerd peak value at i-1.
-            float t0 = u[i_start].t;
-            float t1 = u[i].t;
-
-            peaks.push_back({(t0+t1)/2, u[i-1].v, (t1-t0)/2});
-        }
-
-        if (s!=0) {
-            s_prev = s;
-            if (s_prev>0) {
-                i_start = i-1;
-            }
-        }
-    }
-    return peaks;
-}
-
-util::optional<trace_peak> peak_delta(const trace_data<double>& a, const trace_data<double>& b) {
-    auto p = local_maxima(a);
-    auto q = local_maxima(b);
-
-    if (p.size()!=q.size() || p.empty()) return util::nullopt;
-
-    auto max_delta = p[0]-q[0];
-
-    for (std::size_t i = 1u; i<p.size(); ++i) {
-        auto delta = p[i]-q[i];
-        // pick maximum delta by greatest lower bound on dt
-        if (std::abs(delta.t)-delta.t_err>std::abs(max_delta.t)-max_delta.t_err) {
-            max_delta = delta;
-        }
-    }
-    return max_delta;
-}
-
-} // namespace arb
-
diff --git a/test/validation/trace_analysis.hpp b/test/validation/trace_analysis.hpp
deleted file mode 100644
index 92b8d04811f5666bd7ec5d932b0b7544944b3545..0000000000000000000000000000000000000000
--- a/test/validation/trace_analysis.hpp
+++ /dev/null
@@ -1,109 +0,0 @@
-#pragma once
-
-#include <cfloat>
-#include <vector>
-
-#include "../gtest.h"
-
-#include <arbor/simple_sampler.hpp>
-#include <arbor/util/optional.hpp>
-
-#include "util.hpp"
-
-namespace arb {
-
-/* Trace data comparison */
-
-// Compute max |v_i - f(t_i)| where (t, v) is the 
-// first trace `u` and f is the piece-wise linear interpolant
-// of the second trace `r`.
-
-double linf_distance(const trace_data<double>& u, const trace_data<double>& r);
-
-// Compute linf distance as above, excluding samples in the first trace
-// near times given in `excl`, monotonically increasing.
-
-double linf_distance(const trace_data<double>& u, const trace_data<double>& r, const std::vector<float>& excl);
-
-// Find local maxima (peaks) in a trace, excluding end points.
-
-struct trace_peak {
-    float t;
-    double v;
-    float t_err;
-
-    friend trace_peak operator-(trace_peak x, trace_peak y) {
-        return {x.t-y.t, x.v-y.v, x.t_err+y.t_err};
-    }
-};
-
-std::vector<trace_peak> local_maxima(const trace_data<double>& u);
-
-// Compare differences in peak times across two traces.
-// Returns largest magnitute displacement between peaks,
-// together with a sampling error bound, or `nothing`
-// if the number of peaks differ.
-
-util::optional<trace_peak> peak_delta(const trace_data<double>& a, const trace_data<double>& b);
-
-// Record for error data for convergence testing.
-// Only linf and peak_delta are used for convergence testing below;
-// if and param are for record keeping in the validation test itself.
-
-template <typename Param>
-struct conv_entry {
-    std::string id;
-    Param param;
-    double linf;
-    util::optional<trace_peak> peak_delta;
-};
-
-template <typename Param>
-using conv_data = std::vector<conv_entry<Param>>;
-
-// Assert error convergence (gtest).
-
-template <typename ConvEntrySeq>
-void assert_convergence(const ConvEntrySeq& cs) {
-    if (size(cs)==0) return;
-
-    auto tbound = [](trace_peak p) { return std::abs(p.t)+p.t_err; };
-    float peak_dt_bound = INFINITY;
-
-    for (auto pi = std::begin(cs); std::next(pi)!=std::end(cs); ++pi) {
-        const auto& p = *pi;
-        const auto& c = *std::next(pi);
-
-        EXPECT_LE(c.linf, p.linf) << "L∞ error increase";
-
-        if (!c.peak_delta) {
-            EXPECT_FALSE(p.peak_delta) << "divergence in peak count";
-        }
-        else {
-            double t = std::abs(c.peak_delta->t);
-            double t_limit = c.peak_delta->t_err+peak_dt_bound;
-
-            EXPECT_LE(t, t_limit) << "divergence in max peak displacement";
-
-            peak_dt_bound = std::min(peak_dt_bound, tbound(*c.peak_delta));
-        }
-    }
-}
-
-// Report table of convergence results.
-
-template <typename ConvEntrySeq>
-void report_conv_table(std::ostream& out, const ConvEntrySeq& tbl, const std::string& param_name) {
-    out << "id," << param_name << ",linf,peak_dt,peak_dt_err\n";
-    for (const auto& c: tbl) {
-        out << c.id << "," << c.param << "," << c.linf << ",";
-        if (c.peak_delta) {
-            out << c.peak_delta->t << "," << c.peak_delta->t_err << "\n";
-        }
-        else {
-            out << "NA,NA\n";
-        }
-    }
-}
-
-} // namespace arb
diff --git a/test/validation/util.hpp b/test/validation/util.hpp
deleted file mode 100644
index af02639276c7c58ec8ec0ed7215efd8455c28d78..0000000000000000000000000000000000000000
--- a/test/validation/util.hpp
+++ /dev/null
@@ -1,22 +0,0 @@
-#pragma once
-
-// Simple helper utilities for validation tests.
-
-#include <sstream>
-#include <string>
-
-#include <arbor/common_types.hpp>
-
-template <typename T, std::size_t N>
-constexpr std::size_t size(T (&)[N]) noexcept {
-    return N;
-}
-
-template <typename X>
-constexpr std::size_t size(const X& x) { return x.size(); }
-
-inline std::string to_string(arb::backend_kind kind) {
-    std::stringstream out;
-    out << kind;
-    return out.str();
-}
diff --git a/test/validation/validate.cpp b/test/validation/validate.cpp
deleted file mode 100644
index 082937079028043123ac89a32707531c028a4415..0000000000000000000000000000000000000000
--- a/test/validation/validate.cpp
+++ /dev/null
@@ -1,80 +0,0 @@
-#include <cstring>
-#include <iostream>
-#include <sstream>
-#include <string>
-#include <exception>
-
-#include <sup/tinyopt.hpp>
-
-#include "../gtest.h"
-
-#include "validation_data.hpp"
-
-using namespace arb;
-
-const char* usage_str =
-"[OPTION]...\n"
-"\n"
-"  -v, --verbose       Print results to stdout\n"
-"  -o, --output=FILE   Save traces from simulations to FILE\n"
-"  -p, --path=DIR      Look for validation reference data in DIR\n"
-"  -m, --max-comp=N    Run convergence tests to a maximum of N\n"
-"                      compartments per segment\n"
-"  -d, --min-dt=DT     Run convergence tests with a minimum timestep DT [ms]\n"
-"  -s, --sample-dt=DT  Sample rate for simulations [ms]\n"
-"  -h, --help          Display usage information and exit\n"
-"\n"
-"Validation data is searched by default in the directory specified by\n"
-"ARB_DATADIR at compile time. If ARB_DATADIR does not correspond to a\n"
-"directory, the tests will try the paths './validation/data' and\n"
-"'../validation/data'. This default path can be overridden with the\n"
-"ARB_DATADIR environment variable, or with the -p command-line option.\n";
-
-int main(int argc, char **argv) {
-    using to::parse_opt;
-
-    ::testing::InitGoogleTest(&argc, argv);
-
-    try {
-        auto arg = argv+1;
-        while (*arg) {
-            if (auto o = parse_opt<std::string>(arg, 'p', "path")) {
-                g_trace_io.set_datadir(*o);
-            }
-            else if (auto o = parse_opt<std::string>(arg, 'o', "output")) {
-                g_trace_io.set_output(*o);
-            }
-            else if (auto o = parse_opt<int>(arg, 'm', "max-comp")) {
-                g_trace_io.set_max_ncomp(*o);
-            }
-            else if (auto o = parse_opt<float>(arg, 'd', "min-dt")) {
-                g_trace_io.set_min_dt(*o);
-            }
-            else if (auto o = parse_opt<float>(arg, 's', "sample-dt")) {
-                g_trace_io.set_sample_dt(*o);
-            }
-            else if (auto o = parse_opt(arg, 'v', "verbose")) {
-                g_trace_io.set_verbose(true);
-            }
-            else if (auto o = parse_opt(arg, 'h', "help")) {
-                to::usage(argv[0], usage_str);
-                return 0;
-            }
-            else {
-                throw to::parse_opt_error(*arg, "unrecognized option");
-            }
-        }
-
-        return RUN_ALL_TESTS();
-    }
-    catch (to::parse_opt_error& e) {
-        to::usage(argv[0], usage_str, e.what());
-        return 1;
-    }
-    catch (std::exception& e) {
-        std::cerr << "caught exception: " << e.what() << "\n";
-        return 2;
-    }
-
-    return 0;
-}
diff --git a/test/validation/validate_ball_and_stick.cpp b/test/validation/validate_ball_and_stick.cpp
deleted file mode 100644
index 4f60de9e945b0b568d5d4fbb1cef29dbe32c563b..0000000000000000000000000000000000000000
--- a/test/validation/validate_ball_and_stick.cpp
+++ /dev/null
@@ -1,258 +0,0 @@
-#include <iostream>
-
-#include <nlohmann/json.hpp>
-
-#include <arbor/common_types.hpp>
-#include <arbor/context.hpp>
-#include <arbor/domain_decomposition.hpp>
-#include <arbor/context.hpp>
-#include <arbor/load_balance.hpp>
-#include <arbor/cable_cell.hpp>
-#include <arbor/recipe.hpp>
-#include <arbor/simple_sampler.hpp>
-#include <arbor/simulation.hpp>
-#include <sup/path.hpp>
-
-#include "../common_cells.hpp"
-#include "../simple_recipes.hpp"
-
-#include "convergence_test.hpp"
-#include "trace_analysis.hpp"
-#include "util.hpp"
-#include "validation_data.hpp"
-
-#include "../gtest.h"
-
-using namespace arb;
-
-struct probe_point {
-    const char* label;
-    mlocation where;
-};
-
-template <typename ProbePointSeq>
-void run_ncomp_convergence_test(
-    const char* model_name,
-    const sup::path& ref_data_path,
-    context& context,
-    const cable_cell& c,
-    ProbePointSeq& probe_points,
-    float t_end=100.f)
-{
-    using namespace arb;
-
-    auto max_ncomp = g_trace_io.max_ncomp();
-    auto dt = g_trace_io.min_dt();
-    auto sample_dt = g_trace_io.sample_dt();
-
-    nlohmann::json meta = {
-        {"name", "membrane voltage"},
-        {"model", model_name},
-        {"dt", dt},
-        {"sim", "arbor"},
-        {"units", "mV"},
-        {"backend_kind", (has_gpu(context)? "gpu": "multicore")}
-    };
-
-    auto exclude = stimulus_ends(c);
-
-    auto n_probe = size(probe_points);
-    std::vector<probe_label> plabels;
-    plabels.reserve(n_probe);
-    for (unsigned i = 0; i<n_probe; ++i) {
-        plabels.push_back(probe_label{probe_points[i].label, {0u, i}});
-    }
-
-    convergence_test_runner<int> runner("ncomp", plabels, meta);
-    runner.load_reference_data(ref_data_path);
-
-    for (int ncomp = 10; ncomp<max_ncomp; ncomp*=2) {
-        for (auto& seg: c.segments()) {
-            if (!seg->is_soma()) {
-                seg->set_compartments(ncomp);
-            }
-        }
-        cable1d_recipe rec{c};
-        for (const auto& p: probe_points) {
-            rec.add_probe(0, 0, cell_probe_address{p.where, cell_probe_address::membrane_voltage});
-        }
-
-        auto decomp = partition_load_balance(rec, context);
-        simulation sim(rec, decomp, context);
-
-        runner.run(sim, ncomp, sample_dt, t_end, dt, exclude);
-    }
-    runner.report();
-    runner.assert_all_convergence();
-}
-
-void validate_ball_and_stick(context& ctx) {
-    using namespace arb;
-
-    cable_cell c = make_cell_ball_and_stick();
-    probe_point points[] = {
-        {"soma.mid", {0u, 0.5}},
-        {"dend.mid", {1u, 0.5}},
-        {"dend.end", {1u, 1.0}}
-    };
-
-    run_ncomp_convergence_test(
-        "ball_and_stick",
-        "neuron_ball_and_stick.json",
-        ctx,
-        c,
-        points);
-}
-
-void validate_ball_and_taper(context& ctx) {
-    using namespace arb;
-
-    cable_cell c = make_cell_ball_and_taper();
-    probe_point points[] = {
-        {"soma.mid",  {0u, 0.5}},
-        {"taper.mid", {1u, 0.5}},
-        {"taper.end", {1u, 1.0}}
-    };
-
-    run_ncomp_convergence_test(
-        "ball_and_taper",
-        "neuron_ball_and_taper.json",
-        ctx,
-        c,
-        points);
-}
-
-void validate_ball_and_3stick(context& ctx) {
-    using namespace arb;
-
-    cable_cell c = make_cell_ball_and_3stick();
-    probe_point points[] = {
-        {"soma.mid",  {0u, 0.5}},
-        {"dend1.mid", {1u, 0.5}},
-        {"dend1.end", {1u, 1.0}},
-        {"dend2.mid", {2u, 0.5}},
-        {"dend2.end", {2u, 1.0}},
-        {"dend3.mid", {3u, 0.5}},
-        {"dend3.end", {3u, 1.0}}
-    };
-
-    run_ncomp_convergence_test(
-        "ball_and_3stick",
-        "neuron_ball_and_3stick.json",
-        ctx,
-        c,
-        points);
-}
-
-void validate_rallpack1(context& ctx) {
-    using namespace arb;
-
-    cable_cell c = make_cell_simple_cable();
-    probe_point points[] = {
-        {"cable.x0.0", {1u, 0.0}},
-        {"cable.x0.3", {1u, 0.3}},
-        {"cable.x1.0", {1u, 1.0}}
-    };
-
-    run_ncomp_convergence_test(
-        "rallpack1",
-        "numeric_rallpack1.json",
-        ctx,
-        c,
-        points,
-        250.f);
-}
-
-void validate_ball_and_squiggle(context& ctx) {
-    using namespace arb;
-
-    cable_cell c = make_cell_ball_and_squiggle();
-    probe_point points[] = {
-        {"soma.mid", {0u, 0.5}},
-        {"dend.mid", {1u, 0.5}},
-        {"dend.end", {1u, 1.0}}
-    };
-
-#if 0
-    // *temporarily* disabled: compartment division policy will
-    // be moved into backend policy classes.
-
-    run_ncomp_convergence_test<lowered_cell_div<div_compartment_sampler>>(
-        "ball_and_squiggle_sampler",
-        "neuron_ball_and_squiggle.json",
-        c,
-        samplers);
-#endif
-
-    run_ncomp_convergence_test(
-        "ball_and_squiggle_integrator",
-        "neuron_ball_and_squiggle.json",
-        ctx,
-        c,
-        points);
-}
-
-TEST(ball_and_stick, neuron_ref) {
-    proc_allocation resources;
-    {
-        auto ctx = make_context(resources);
-        validate_ball_and_stick(ctx);
-    }
-    if (resources.has_gpu()) {
-        resources.gpu_id = -1;
-        auto ctx = make_context(resources);
-        validate_ball_and_stick(ctx);
-    }
-}
-
-TEST(ball_and_taper, neuron_ref) {
-    proc_allocation resources;
-    {
-        auto ctx = make_context(resources);
-        validate_ball_and_taper(ctx);
-    }
-    if (resources.has_gpu()) {
-        resources.gpu_id = -1;
-        auto ctx = make_context(resources);
-        validate_ball_and_taper(ctx);
-    }
-}
-
-TEST(ball_and_3stick, neuron_ref) {
-    proc_allocation resources;
-    {
-        auto ctx = make_context(resources);
-        validate_ball_and_3stick(ctx);
-    }
-    if (resources.has_gpu()) {
-        resources.gpu_id = -1;
-        auto ctx = make_context(resources);
-        validate_ball_and_3stick(ctx);
-    }
-}
-
-TEST(rallpack1, numeric_ref) {
-    proc_allocation resources;
-    {
-        auto ctx = make_context(resources);
-        validate_rallpack1(ctx);
-    }
-    if (resources.has_gpu()) {
-        resources.gpu_id = -1;
-        auto ctx = make_context(resources);
-        validate_rallpack1(ctx);
-    }
-}
-
-TEST(ball_and_squiggle, neuron_ref) {
-    proc_allocation resources;
-    {
-        auto ctx = make_context(resources);
-        validate_ball_and_squiggle(ctx);
-    }
-    if (resources.has_gpu()) {
-        resources.gpu_id = -1;
-        auto ctx = make_context(resources);
-        validate_ball_and_squiggle(ctx);
-    }
-}
diff --git a/test/validation/validate_kinetic.cpp b/test/validation/validate_kinetic.cpp
deleted file mode 100644
index e855d02187d5f203dc74a6108255112fe37b8f0b..0000000000000000000000000000000000000000
--- a/test/validation/validate_kinetic.cpp
+++ /dev/null
@@ -1,134 +0,0 @@
-#include "../gtest.h"
-
-#include <string>
-
-#include <nlohmann/json.hpp>
-
-#include <arbor/context.hpp>
-#include <arbor/common_types.hpp>
-#include <arbor/domain_decomposition.hpp>
-#include <arbor/load_balance.hpp>
-#include <arbor/cable_cell.hpp>
-#include <arbor/recipe.hpp>
-#include <arbor/simple_sampler.hpp>
-#include <arbor/simulation.hpp>
-
-#include "../common_cells.hpp"
-#include "../simple_recipes.hpp"
-
-#include "convergence_test.hpp"
-#include "trace_analysis.hpp"
-#include "util.hpp"
-#include "validation_data.hpp"
-
-void run_kinetic_dt(
-    const arb::context& context,
-    arb::cable_cell& c,
-    arb::cell_probe_address probe,
-    float t_end,
-    nlohmann::json meta,
-    const std::string& ref_file)
-{
-    using namespace arb;
-
-    float sample_dt = g_trace_io.sample_dt();
-
-    cable1d_recipe rec{c};
-    rec.add_probe(0, 0, probe);
-
-    probe_label plabels[1] = {{"soma.mid", {0u, 0u}}};
-
-    meta["sim"] = "arbor";
-    meta["backend_kind"] = arb::has_gpu(context)? "gpu": "multicore";
-
-    convergence_test_runner<float> runner("dt", plabels, meta);
-    runner.load_reference_data(ref_file);
-
-    auto decomp = partition_load_balance(rec, context);
-    simulation sim(rec, decomp, context);
-
-    auto exclude = stimulus_ends(c);
-
-    // use dt = 0.05, 0.02, 0.01, 0.005, 0.002,  ...
-    double max_oo_dt = std::round(1.0/g_trace_io.min_dt());
-    for (double base = 100; ; base *= 10) {
-        for (double multiple: {5., 2., 1.}) {
-            double oo_dt = base/multiple;
-            if (oo_dt>max_oo_dt) goto end;
-
-            sim.reset();
-            float dt = float(1./oo_dt);
-            runner.run(sim, dt, sample_dt, t_end, dt, exclude);
-        }
-    }
-
-end:
-    runner.report();
-    runner.assert_all_convergence();
-}
-
-void validate_kinetic_kin1(const arb::context& ctx) {
-    using namespace arb;
-
-    // 20 µm diameter soma with single mechanism, current probe
-    cable_cell c;
-    auto soma = c.add_soma(10);
-    soma->add_mechanism("test_kin1");
-    cell_probe_address probe{{0, 0.5}, cell_probe_address::membrane_current};
-
-    nlohmann::json meta = {
-        {"model", "test_kin1"},
-        {"name", "membrane current"},
-        {"units", "nA"}
-    };
-
-    run_kinetic_dt(ctx, c, probe, 100.f, meta, "numeric_kin1.json");
-}
-
-void validate_kinetic_kinlva(const arb::context& ctx) {
-    using namespace arb;
-
-    // 20 µm diameter soma with single mechanism, current probe
-    cable_cell c;
-    auto soma = c.add_soma(10);
-    c.place(mlocation{0,0.5}, {20., 130., -0.025});
-    soma->add_mechanism("test_kinlva");
-    cell_probe_address probe{{0, 0.5}, cell_probe_address::membrane_voltage};
-
-    nlohmann::json meta = {
-        {"model", "test_kinlva"},
-        {"name", "membrane voltage"},
-        {"units", "mV"}
-    };
-
-    run_kinetic_dt(ctx, c, probe, 300.f, meta, "numeric_kinlva.json");
-}
-
-
-using namespace arb;
-
-TEST(kinetic, kin1_numeric_ref) {
-    proc_allocation resources;
-    {
-        auto ctx = make_context(resources);
-        validate_kinetic_kin1(ctx);
-    }
-    if (resources.has_gpu()) {
-        resources.gpu_id = -1;
-        auto ctx = make_context(resources);
-        validate_kinetic_kin1(ctx);
-    }
-}
-
-TEST(kinetic, kinlva_numeric_ref) {
-    proc_allocation resources;
-    {
-        auto ctx = make_context(resources);
-        validate_kinetic_kinlva(ctx);
-    }
-    if (resources.has_gpu()) {
-        resources.gpu_id = -1;
-        auto ctx = make_context(resources);
-        validate_kinetic_kinlva(ctx);
-    }
-}
diff --git a/test/validation/validate_soma.cpp b/test/validation/validate_soma.cpp
deleted file mode 100644
index 6d5c9c6db7e4697b1b8b3cbb03fcb96d1baf6409..0000000000000000000000000000000000000000
--- a/test/validation/validate_soma.cpp
+++ /dev/null
@@ -1,78 +0,0 @@
-#include <nlohmann/json.hpp>
-
-#include <arbor/common_types.hpp>
-#include <arbor/context.hpp>
-#include <arbor/domain_decomposition.hpp>
-#include <arbor/load_balance.hpp>
-#include <arbor/cable_cell.hpp>
-#include <arbor/recipe.hpp>
-#include <arbor/simple_sampler.hpp>
-#include <arbor/simulation.hpp>
-
-#include "../common_cells.hpp"
-#include "../simple_recipes.hpp"
-
-#include "convergence_test.hpp"
-#include "trace_analysis.hpp"
-#include "util.hpp"
-#include "validation_data.hpp"
-
-#include "../gtest.h"
-
-using namespace arb;
-
-void validate_soma(const context& context) {
-    float sample_dt = g_trace_io.sample_dt();
-
-    cable_cell c = make_cell_soma_only();
-
-    cable1d_recipe rec{c};
-    rec.add_probe(0, 0, cell_probe_address{{0, 0.5}, cell_probe_address::membrane_voltage});
-    probe_label plabels[1] = {{"soma.mid", {0u, 0u}}};
-
-    auto decomp = partition_load_balance(rec, context);
-    simulation sim(rec, decomp, context);
-
-    nlohmann::json meta = {
-        {"name", "membrane voltage"},
-        {"model", "soma"},
-        {"sim", "arbor"},
-        {"units", "mV"},
-        {"backend_kind", has_gpu(context)? "gpu": "multicore"}
-    };
-
-    convergence_test_runner<float> runner("dt", plabels, meta);
-    runner.load_reference_data("numeric_soma.json");
-
-    float t_end = 100.f;
-
-    // use dt = 0.05, 0.02, 0.01, 0.005, 0.002,  ...
-    double max_oo_dt = std::round(1.0/g_trace_io.min_dt());
-    for (double base = 100; ; base *= 10) {
-        for (double multiple: {5., 2., 1.}) {
-            double oo_dt = base/multiple;
-            if (oo_dt>max_oo_dt) goto end;
-
-            sim.reset();
-            float dt = float(1./oo_dt);
-            runner.run(sim, dt, sample_dt, t_end, dt, {});
-        }
-    }
-end:
-
-    runner.report();
-    runner.assert_all_convergence();
-}
-
-TEST(soma, numeric_ref) {
-    proc_allocation resources;
-    {
-        auto ctx = make_context(resources);
-        validate_soma(ctx);
-    }
-    if (resources.has_gpu()) {
-        resources.gpu_id = -1;
-        auto ctx = make_context(resources);
-        validate_soma(ctx);
-    }
-}
diff --git a/test/validation/validate_synapses.cpp b/test/validation/validate_synapses.cpp
deleted file mode 100644
index daa3a209ff9fb98a53b88884b58aa4f6d98ff885..0000000000000000000000000000000000000000
--- a/test/validation/validate_synapses.cpp
+++ /dev/null
@@ -1,115 +0,0 @@
-#include <nlohmann/json.hpp>
-
-#include <arbor/context.hpp>
-#include <arbor/domain_decomposition.hpp>
-#include <arbor/load_balance.hpp>
-#include <arbor/cable_cell.hpp>
-#include <arbor/recipe.hpp>
-#include <arbor/simple_sampler.hpp>
-#include <arbor/simulation.hpp>
-#include <sup/path.hpp>
-
-
-#include "../gtest.h"
-
-#include "../common_cells.hpp"
-#include "../simple_recipes.hpp"
-
-#include "convergence_test.hpp"
-#include "trace_analysis.hpp"
-#include "util.hpp"
-#include "validation_data.hpp"
-
-using namespace arb;
-
-void run_synapse_test(
-    const char* syn_type,
-    const sup::path& ref_data_path,
-    const context& context,
-    float t_end=70.f,
-    float dt=0.001)
-{
-    auto max_ncomp = g_trace_io.max_ncomp();
-    nlohmann::json meta = {
-        {"name", "membrane voltage"},
-        {"model", syn_type},
-        {"sim", "arbor"},
-        {"units", "mV"},
-        {"backend_kind", arb::has_gpu(context)? "gpu": "multicore"}
-    };
-
-    cable_cell c = make_cell_ball_and_stick(false); // no stimuli
-    mechanism_desc syn_default(syn_type);
-    c.place(mlocation{1, 0.5}, syn_default);
-
-    // injected spike events
-    std::vector<spike_event> synthetic_events = {
-        {{0u, 0u}, 10.0, 0.04},
-        {{0u, 0u}, 20.0, 0.04},
-        {{0u, 0u}, 40.0, 0.04}
-    };
-
-    // exclude points of discontinuity from linf analysis
-    std::vector<float> exclude = {10.f, 20.f, 40.f};
-
-    float sample_dt = g_trace_io.sample_dt();
-    probe_label plabels[3] = {
-        {"soma.mid", {0u, 0u}},
-        {"dend.mid", {0u, 1u}},
-        {"dend.end", {0u, 2u}}
-    };
-
-    convergence_test_runner<int> runner("ncomp", plabels, meta);
-    runner.load_reference_data(ref_data_path);
-
-    for (int ncomp = 10; ncomp<max_ncomp; ncomp*=2) {
-        c.cable(1)->set_compartments(ncomp);
-
-        cable1d_recipe rec{c};
-        // soma.mid
-        rec.add_probe(0, 0, cell_probe_address{{0, 0.5}, cell_probe_address::membrane_voltage});
-        // dend.mid
-        rec.add_probe(0, 0, cell_probe_address{{1, 0.5}, cell_probe_address::membrane_voltage});
-        // dend.end
-        rec.add_probe(0, 0, cell_probe_address{{1, 1.0}, cell_probe_address::membrane_voltage});
-
-        auto decomp = partition_load_balance(rec, context);
-        simulation sim(rec, decomp, context);
-
-        sim.inject_events(synthetic_events);
-
-        runner.run(sim, ncomp, sample_dt, t_end, dt, exclude);
-    }
-    runner.report();
-    runner.assert_all_convergence();
-}
-
-TEST(simple_synapse, expsyn_neuron_ref) {
-    proc_allocation resources;
-    {
-        auto ctx = make_context(resources);
-        SCOPED_TRACE("expsyn-multicore");
-        run_synapse_test("expsyn", "neuron_simple_exp_synapse.json", ctx);
-    }
-    if (resources.has_gpu()) {
-        resources.gpu_id = -1;
-        auto ctx = make_context(resources);
-        SCOPED_TRACE("expsyn-gpu");
-        run_synapse_test("expsyn", "neuron_simple_exp_synapse.json", ctx);
-    }
-}
-
-TEST(simple_synapse, exp2syn_neuron_ref) {
-    proc_allocation resources;
-    {
-        auto ctx = make_context(resources);
-        SCOPED_TRACE("exp2syn-multicore");
-        run_synapse_test("exp2syn", "neuron_simple_exp_synapse.json", ctx);
-    }
-    if (resources.has_gpu()) {
-        resources.gpu_id = -1;
-        auto ctx = make_context(resources);
-        SCOPED_TRACE("exp2syn-gpu");
-        run_synapse_test("exp2syn", "neuron_simple_exp_synapse.json", ctx);
-    }
-}
diff --git a/test/validation/validation_data.cpp b/test/validation/validation_data.cpp
deleted file mode 100644
index 3ddbaaa7aadc002389203361de2db902648a8088..0000000000000000000000000000000000000000
--- a/test/validation/validation_data.cpp
+++ /dev/null
@@ -1,116 +0,0 @@
-#include <algorithm>
-#include <cstdlib>
-#include <fstream>
-#include <stdexcept>
-#include <string>
-
-#include <nlohmann/json.hpp>
-
-#include <arbor/simple_sampler.hpp>
-#include <sup/path.hpp>
-
-#include "trace_analysis.hpp"
-#include "validation_data.hpp"
-
-namespace arb {
-
-trace_io g_trace_io;
-
-#ifndef ARB_DATADIR
-#define ARB_DATADIR ""
-#endif
-
-sup::path trace_io::find_datadir() {
-    // If environment variable is set, use that in preference.
-
-    if (const char* env_path = std::getenv("ARB_DATADIR")) {
-        return env_path;
-    }
-
-    // Otherwise try compile-time path ARB_DATADIR and hard-coded
-    // relative paths below in turn, returning the first that
-    // corresponds to an existing directory.
-
-    const char* paths[] = {
-        ARB_DATADIR,
-        "./validation/data",
-        "../validation/data"
-    };
-
-    std::error_code ec;
-    for (auto p: paths) {
-        if (sup::is_directory(p, ec)) {
-            return p;
-        }
-    }
-
-    // Otherwise set to empty path, and rely on command-line option.
-    return "";
-}
-
-void trace_io::save_trace(const std::string& label, const trace_data<double>& data, const nlohmann::json& meta) {
-    save_trace("time", label, data, meta);
-}
-
-void trace_io::save_trace(const std::string& abscissa, const std::string& label, const trace_data<double>& data, const nlohmann::json& meta) {
-    using nlohmann::json;
-
-    json j = meta;
-    json& times = j["data"][abscissa];
-    json& values = j["data"][label];
-
-    for (const auto& e: data) {
-        times.push_back(e.t);
-        values.push_back(e.v);
-    }
-
-    jtraces_ += std::move(j);
-}
-
-template <typename Seq1, typename Seq2>
-static trace_data<double> zip_trace_data(const Seq1& ts, const Seq2& vs) {
-    trace_data<double> trace;
-
-    auto ti = std::begin(ts);
-    auto te = std::end(ts);
-    auto vi = std::begin(vs);
-    auto ve = std::end(vs);
-
-    while (ti!=te && vi!=ve) {
-        trace.push_back({*ti++, *vi++});
-    }
-    return trace;
-}
-
-static void parse_trace_json(const nlohmann::json& j, std::map<std::string, trace_data<double>>& traces) {
-    if (j.is_array()) {
-        for (auto& i: j) parse_trace_json(i, traces);
-    }
-    else if (j.is_object() && j.count("data")>0 && j["data"].count("time")>0) {
-        auto data = j["data"];
-        auto time = data["time"].get<std::vector<float>>();
-        for (const auto& p: nlohmann::json::iterator_wrapper(data)) {
-            if (p.key()=="time") continue;
-
-            traces[p.key()] = zip_trace_data(time, p.value().get<std::vector<double>>());
-        }
-    }
-}
-
-std::map<std::string, trace_data<double>> trace_io::load_traces(const sup::path& name) {
-    sup::path file  = datadir_/name;
-    std::ifstream fid(file);
-    if (!fid) {
-        throw std::runtime_error("unable to load validation data: "+file.native());
-    }
-
-    nlohmann::json data;
-    fid >> data;
-
-    std::map<std::string, trace_data<double>> traces;
-    parse_trace_json(data, traces);
-    return traces;
-}
-
-} // namespace arb
-
diff --git a/test/validation/validation_data.hpp b/test/validation/validation_data.hpp
deleted file mode 100644
index bf827bf1198dd2a8272759ad53d4baa871834a5a..0000000000000000000000000000000000000000
--- a/test/validation/validation_data.hpp
+++ /dev/null
@@ -1,101 +0,0 @@
-#pragma once
-
-#include <fstream>
-#include <stdexcept>
-#include <string>
-#include <utility>
-
-#include <nlohmann/json.hpp>
-
-#include <arbor/simple_sampler.hpp>
-#include <sup/path.hpp>
-
-namespace arb {
-
-/*
- * Class manages input (loading and parsing) of JSON
- * reference traces, and output of saved traces from
- * the validation tests.
- *
- * Global object has paths initalised by the test
- * main() and will write all saved traces to a single
- * output JSON file, if specified.
- */
-
-// TODO: split simulation options from trace_io structure; rename
-// file to e.g. 'trace_io.hpp'
-
-class trace_io {
-public:
-    // Try to find the data directory on construction.
-
-    trace_io() {
-        datadir_ = find_datadir();
-    }
-
-    void clear_traces() {
-        jtraces_ = nlohmann::json::array();
-    }
-
-    void write_traces() {
-        if (out_) {
-            out_ << jtraces_;
-            out_.close();
-        }
-    }
-
-    void save_trace(const std::string& label, const trace_data<double>& data, const nlohmann::json& meta);
-    void save_trace(const std::string& abscissa, const std::string& label, const trace_data<double>& data, const nlohmann::json& meta);
-    std::map<std::string, trace_data<double>> load_traces(const sup::path& name);
-
-    // common flags, options set by driver
-
-    void set_verbose(bool v) { verbose_flag_ = v; }
-    bool verbose() const { return verbose_flag_; }
-
-    void set_max_ncomp(int ncomp) { max_ncomp_ = ncomp; }
-    int max_ncomp() const { return max_ncomp_; }
-
-    void set_min_dt(float dt) { min_dt_ = dt; }
-    float min_dt() const { return min_dt_; }
-
-    void set_sample_dt(float dt) { sample_dt_ = dt; }
-    float sample_dt() const { return sample_dt_; }
-
-    void set_datadir(const sup::path& dir) { datadir_ = dir; }
-
-    void set_output(const sup::path& file) {
-        out_.open(file);
-        if (!out_) {
-            throw std::runtime_error("unable to open file for writing");
-        }
-    }
-
-    // Write traces on exit.
-
-    ~trace_io() {
-        if (out_) {
-            write_traces();
-        }
-    }
-
-private:
-    sup::path datadir_;
-    std::ofstream out_;
-    nlohmann::json jtraces_ = nlohmann::json::array();
-    bool verbose_flag_ = false;
-    int max_ncomp_ = 100;
-    float min_dt_ = 0.001f;
-    float sample_dt_ = 0.005f;
-
-    // Returns value of ARB_DATADIR environment variable if set,
-    // otherwise make a 'best-effort' search for the data directory,
-    // starting with ARB_DATADIR preprocessor define if defined and
-    // if the directory exists, or else try './validation/data'
-    // and '../validation/data'.
-    static sup::path find_datadir();
-};
-
-extern trace_io g_trace_io;
-
-} // namespace arb
diff --git a/todo.md b/todo.md
new file mode 100644
index 0000000000000000000000000000000000000000..150c42584f1639783a2b4284bb09aaaeed5e1fec
--- /dev/null
+++ b/todo.md
@@ -0,0 +1,5 @@
+Things to do in morophology part 3.
+
+* add ability to set the number of compartments per branch (removed by allowing only sample_tree descriptions)
+* remove `cable_cell::place(mlocation, ...)` with `cable_cell::place(locset, ...)`
+    * ensure consistent error messages in new code