From a6ddd515364cbe9e81a3fb24e96276b9608c29c9 Mon Sep 17 00:00:00 2001
From: akuesters <42005107+akuesters@users.noreply.github.com>
Date: Tue, 4 Jun 2019 16:32:38 +0200
Subject: [PATCH] Py feature recipe wo probes (#768)

Fixes #760

Wraps arbor recipe (without probes, i.e. num_probes, probe_info, get_probe) including

- cell_connection,
- gap_junction_connection,
- recipe with
  - num_cells
  - cell_description
  - cell_kind
  - num_sources
  - num_targets
  - num_gap_junctions_sites
  - event_generators
  - connections_on
  - gap_junctions_on
  - global_properties
- enum cell_kind in `identifiers.cpp`
---
 python/CMakeLists.txt                     |   1 +
 python/context.cpp                        |   7 +-
 python/event_generator.cpp                |  44 ++--
 python/event_generator.hpp                |  22 ++
 python/identifiers.cpp                    |  11 +
 python/mpi.cpp                            |   2 +-
 python/pyarb.cpp                          |   2 +
 python/recipe.cpp                         | 232 ++++++++++++++++++++++
 python/recipe.hpp                         | 163 +++++++++++++++
 python/test/unit/test_contexts.py         |   8 +-
 python/test/unit/test_event_generators.py |  12 +-
 11 files changed, 461 insertions(+), 43 deletions(-)
 create mode 100644 python/event_generator.hpp
 create mode 100644 python/recipe.cpp
 create mode 100644 python/recipe.hpp

diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt
index 4b37a939..8065522a 100644
--- a/python/CMakeLists.txt
+++ b/python/CMakeLists.txt
@@ -22,6 +22,7 @@ add_library(pyarb MODULE
     identifiers.cpp
     mpi.cpp
     pyarb.cpp
+    recipe.cpp
     strings.cpp
 )
 
diff --git a/python/context.cpp b/python/context.cpp
index 4e7dde4f..04cb3ab0 100644
--- a/python/context.cpp
+++ b/python/context.cpp
@@ -38,11 +38,11 @@ struct proc_allocation_shim {
 
     // getter and setter (in order to assert when being set)
     void set_gpu_id(pybind11::object gpu) {
-        gpu_id = py2optional<int>(gpu, "gpu id must be None, or a non-negative integer.", is_nonneg_int);
+        gpu_id = py2optional<int>(gpu, "gpu_id must be None, or a non-negative integer", is_nonneg_int);
     };
 
     void set_num_threads(int threads) {
-        pyarb::assert_throw([](int n) { return n>0; }(threads), "threads must be a positive integer.");
+        pyarb::assert_throw([](int n) { return n>0; }(threads), "threads must be a positive integer");
         num_threads = threads;
     };
 
@@ -153,8 +153,7 @@ void register_contexts(pybind11::module& m) {
 #else
         .def(pybind11::init(
             [](int threads, pybind11::object gpu){
-                auto gpu_id = py2optional<int>(gpu,
-                        "gpu id must be None, or a non-negative integer.", is_nonneg_int);
+                auto gpu_id = py2optional<int>(gpu, "gpu_id must be None, or a non-negative integer", is_nonneg_int);
                 return context_shim(arb::make_context(arb::proc_allocation(threads, gpu_id.value_or(-1))));
             }),
              "threads"_a=1, "gpu_id"_a=pybind11::none(),
diff --git a/python/event_generator.cpp b/python/event_generator.cpp
index f31bbe15..9344a3fd 100644
--- a/python/event_generator.cpp
+++ b/python/event_generator.cpp
@@ -3,7 +3,6 @@
 #include <string>
 
 #include <arbor/common_types.hpp>
-#include <arbor/event_generator.hpp>
 #include <arbor/schedule.hpp>
 
 #include <pybind11/pybind11.h>
@@ -12,6 +11,7 @@
 
 #include "conversion.hpp"
 #include "error.hpp"
+#include "event_generator.hpp"
 
 namespace pyarb {
 
@@ -41,13 +41,13 @@ struct regular_schedule_shim {
 
     // getter and setter (in order to assert when being set)
     void set_tstart(pybind11::object t) {
-        tstart = py2optional<time_type>(t, "tstart must a non-negative number, or None.", is_nonneg);
+        tstart = py2optional<time_type>(t, "tstart must a non-negative number, or None", is_nonneg);
     };
     void set_tstop(pybind11::object t) {
-        tstop = py2optional<time_type>(t, "tstop must a non-negative number, or None.", is_nonneg);
+        tstop = py2optional<time_type>(t, "tstop must a non-negative number, or None", is_nonneg);
     };
     void set_dt(time_type delta_t) {
-        pyarb::assert_throw(is_nonneg(delta_t), "dt must be a non-negative number.");
+        pyarb::assert_throw(is_nonneg(delta_t), "dt must be a non-negative number");
         dt = delta_t;
     };
 
@@ -90,7 +90,7 @@ struct explicit_schedule_shim {
         // Assert that there are no negative times
         if (times.size()) {
             pyarb::assert_throw(is_nonneg(times[0]),
-                    "explicit time schedule can not contain negative values.");
+                    "explicit time schedule can not contain negative values");
         }
     };
 
@@ -123,12 +123,12 @@ struct poisson_schedule_shim {
     }
 
     void set_tstart(time_type t) {
-        pyarb::assert_throw(is_nonneg(t), "tstart must be a non-negative number.");
+        pyarb::assert_throw(is_nonneg(t), "tstart must be a non-negative number");
         tstart = t;
     };
 
     void set_freq(time_type f) {
-        pyarb::assert_throw(is_nonneg(f), "frequency must be a non-negative number.");
+        pyarb::assert_throw(is_nonneg(f), "frequency must be a non-negative number");
         freq = f;
     };
 
@@ -141,6 +141,15 @@ struct poisson_schedule_shim {
     }
 };
 
+template <typename Sched>
+event_generator_shim make_event_generator(
+        arb::cell_member_type target,
+        double weight,
+        const Sched& sched)
+{
+    return event_generator_shim(target, weight, sched.schedule());
+}
+
 // Helper template for printing C++ optional types in Python.
 // Prints either the value, or None if optional value is not set.
 template <typename T>
@@ -184,27 +193,6 @@ std::string schedule_poisson_string(const poisson_schedule_shim& p) {
     return s.str();
 };
 
-struct event_generator_shim {
-    arb::cell_member_type target;
-    double weight;
-    arb::schedule time_sched;
-
-    event_generator_shim(arb::cell_member_type cell, double event_weight, arb::schedule sched):
-        target(cell),
-        weight(event_weight),
-        time_sched(std::move(sched))
-    {}
-};
-
-template <typename Sched>
-event_generator_shim make_event_generator(
-        arb::cell_member_type target,
-        double weight,
-        const Sched& sched)
-{
-    return event_generator_shim(target, weight, sched.schedule());
-}
-
 void register_event_generators(pybind11::module& m) {
     using namespace pybind11::literals;
     using time_type = arb::time_type;
diff --git a/python/event_generator.hpp b/python/event_generator.hpp
new file mode 100644
index 00000000..ed41c10e
--- /dev/null
+++ b/python/event_generator.hpp
@@ -0,0 +1,22 @@
+#pragma once
+
+#include <arbor/common_types.hpp>
+#include <arbor/schedule.hpp>
+
+#include <pybind11/pybind11.h>
+
+namespace pyarb {
+
+struct event_generator_shim {
+    arb::cell_member_type target;
+    double weight;
+    arb::schedule time_sched;
+
+    event_generator_shim(arb::cell_member_type cell, double event_weight, arb::schedule sched):
+        target(cell),
+        weight(event_weight),
+        time_sched(std::move(sched))
+    {}
+};
+
+} // namespace pyarb
diff --git a/python/identifiers.cpp b/python/identifiers.cpp
index cb6159a2..3aae5a61 100644
--- a/python/identifiers.cpp
+++ b/python/identifiers.cpp
@@ -38,6 +38,17 @@ void register_identifiers(pybind11::module& m) {
             "Cell-local index of the item.")
         .def("__str__",  &cell_member_string)
         .def("__repr__", &cell_member_string);
+
+    pybind11::enum_<arb::cell_kind>(m, "cell_kind",
+        "Enumeration used to identify the cell kind, used by the model to group equal kinds in the same cell group.")
+        .value("benchmark", arb::cell_kind::benchmark,
+            "Proxy cell used for benchmarking.")
+        .value("cable", arb::cell_kind::cable,
+            "A cell with morphology described by branching 1D cable segments.")
+        .value("lif", arb::cell_kind::lif,
+            "Leaky-integrate and fire neuron.")
+        .value("spike_source", arb::cell_kind::spike_source,
+            "Proxy cell that generates spikes from a spike sequence provided by the user.");
 }
 
 } // namespace pyarb
diff --git a/python/mpi.cpp b/python/mpi.cpp
index 16a2b362..15f1b5df 100644
--- a/python/mpi.cpp
+++ b/python/mpi.cpp
@@ -32,7 +32,7 @@ MPI_Comm convert_to_mpi_comm(pybind11::object o) {
         return *PyMPIComm_Get(o.ptr());
     }
 #endif
-    throw arb::mpi_error(MPI_ERR_OTHER, "Unable to convert to an MPI Communicatior.");
+    throw arb::mpi_error(MPI_ERR_OTHER, "Unable to convert to an MPI Communicatior");
 }
 
 mpi_comm_shim::mpi_comm_shim(pybind11::object o) {
diff --git a/python/pyarb.cpp b/python/pyarb.cpp
index e085141e..c964caa4 100644
--- a/python/pyarb.cpp
+++ b/python/pyarb.cpp
@@ -13,6 +13,7 @@ void register_identifiers(pybind11::module& m);
 #ifdef ARB_MPI_ENABLED
 void register_mpi(pybind11::module& m);
 #endif
+void register_recipe(pybind11::module& m);
 }
 
 PYBIND11_MODULE(arbor, m) {
@@ -26,4 +27,5 @@ PYBIND11_MODULE(arbor, m) {
     #ifdef ARB_MPI_ENABLED
     pyarb::register_mpi(m);
     #endif
+    pyarb::register_recipe(m);
 }
diff --git a/python/recipe.cpp b/python/recipe.cpp
new file mode 100644
index 00000000..93d1e2bf
--- /dev/null
+++ b/python/recipe.cpp
@@ -0,0 +1,232 @@
+#include <sstream>
+#include <string>
+#include <vector>
+
+#include <pybind11/pybind11.h>
+#include <pybind11/pytypes.h>
+#include <pybind11/stl.h>
+
+#include <arbor/benchmark_cell.hpp>
+#include <arbor/cable_cell.hpp>
+#include <arbor/event_generator.hpp>
+#include <arbor/lif_cell.hpp>
+#include <arbor/recipe.hpp>
+#include <arbor/spike_source_cell.hpp>
+
+#include "error.hpp"
+#include "event_generator.hpp"
+#include "recipe.hpp"
+
+namespace pyarb {
+
+// ========================================= Unwrap =========================================
+// The py::recipe::cell_decription returns a pybind11::object, that is
+// unwrapped and copied into a arb::util::unique_any.
+
+arb::util::unique_any py_recipe_shim::get_cell_description(arb::cell_gid_type gid) const {
+    using pybind11::isinstance;
+    using pybind11::cast;
+
+    // Aquire the GIL because it must be held when calling isinstance and cast.
+    auto guard = pybind11::gil_scoped_acquire();
+
+    // Get the python object pyarb::cell_description from the python front end
+    pybind11::object o = impl_->cell_description(gid);
+
+    if (isinstance<arb::cable_cell>(o)) {
+        return arb::util::unique_any(cast<arb::cable_cell>(o));
+    }
+
+    else if (isinstance<arb::lif_cell>(o)) {
+        return arb::util::unique_any(cast<arb::lif_cell>(o));
+    }
+
+    else if (isinstance<arb::spike_source_cell>(o)) {
+        return arb::util::unique_any(cast<arb::spike_source_cell>(o));
+    }
+
+    else if (isinstance<arb::benchmark_cell>(o)) {
+        return arb::util::unique_any(cast<arb::benchmark_cell>(o));
+    }
+
+    throw pyarb_error(
+                        "recipe.cell_description returned \""
+                        + std::string(pybind11::str(o))
+                        + "\" which does not describe a known Arbor cell type");
+}
+
+// The py::recipe::global_properties returns a pybind11::object, that is
+// unwrapped and copied into a arb::util::any.
+arb::util::any py_recipe_shim::get_global_properties(arb::cell_kind kind) const {
+    using pybind11::isinstance;
+    using pybind11::cast;
+
+    // Aquire the GIL because it must be held when calling isinstance and cast.
+    auto guard = pybind11::gil_scoped_acquire();
+
+    // Get the python object pyarb::global_properties from the python front end
+    pybind11::object o = impl_->global_properties(kind);
+
+    if (kind == arb::cell_kind::cable) {
+        return arb::util::any(cast<arb::cable_cell_global_properties>(o));
+    }
+
+    else return arb::util::any{};
+
+    throw pyarb_error(
+                        "recipe.global_properties returned \""
+                        + std::string(pybind11::str(o))
+                        + "\" which does not describe a known Arbor global property description");
+
+}
+
+std::vector<arb::event_generator> py_recipe_shim::event_generators(arb::cell_gid_type gid) const {
+    using namespace std::string_literals;
+    using pybind11::isinstance;
+    using pybind11::cast;
+
+    // Aquire the GIL because it must be held when calling isinstance and cast.
+    auto guard = pybind11::gil_scoped_acquire();
+
+    // Get the python list of pyarb::event_generator_shim from the python front end.
+    auto pygens = impl_->event_generators(gid);
+
+    std::vector<arb::event_generator> gens;
+    gens.reserve(pygens.size());
+
+    for (auto& g: pygens) {
+        // check that a valid Python event_generator was passed.
+        if (!isinstance<pyarb::event_generator_shim>(g)) {
+            std::stringstream s;
+            s << "recipe supplied an invalid event generator for gid "
+            << gid << ": " << pybind11::str(g);
+            throw pyarb_error(s.str());
+        }
+        // get a reference to the python event_generator
+        auto& p = cast<const pyarb::event_generator_shim&>(g);
+
+        // convert the event_generator to an arb::event_generator
+        gens.push_back(arb::schedule_generator({gid, p.target.index}, p.weight, std::move(p.time_sched)));
+    }
+
+    return gens;
+}
+
+// TODO: implement py_recipe_shim::probe_info
+
+std::string connection_string(const arb::cell_connection& c) {
+    std::stringstream s;
+    s << "<connection: (" << c.source.gid << "," << c.source.index << ")"
+      << " -> (" << c.dest.gid << "," << c.dest.index << ")"
+      << " , delay " << c.delay << ", weight " << c.weight << ">";
+    return s.str();
+}
+
+std::string gap_junction_connection_string(const arb::gap_junction_connection& gc) {
+    std::stringstream s;
+    s << "<connection: (" << gc.local.gid << "," << gc.local.index << ")"
+    << " -> (" << gc.peer.gid << "," << gc.peer.index << ")"
+    << " , conductance " << gc.ggap << ">";
+    return s.str();
+}
+
+void register_recipe(pybind11::module& m) {
+    using namespace pybind11::literals;
+
+    // Connections
+    pybind11::class_<arb::cell_connection> cell_connection(m, "cell_connection",
+        "Describes a connection between two cells:\n"
+        "a pre-synaptic source and a post-synaptic destination.");
+    cell_connection
+        .def(pybind11::init<>(
+            [](){return arb::cell_connection({0u,0u}, {0u,0u}, 0.f, 0.f);}),
+            "Construct a connection with default arguments:\n"
+            "  source:      gid 0, index 0.\n"
+            "  destination: gid 0, index 0.\n"
+            "  weight:      0.\n"
+            "  delay:       0 ms.\n")
+        .def(pybind11::init<arb::cell_member_type, arb::cell_member_type, float, float>(),
+            "source"_a, "destination"_a, "weight"_a = 0.f, "delay"_a = 0.f,
+            "Construct a connection with arguments:\n"
+            "  source:      The source end point of the connection.\n"
+            "  destination: The destination end point of the connection.\n"
+            "  weight:      The weight delivered to the target synapse (dimensionless with interpretation specific to synapse type of target, default 0.).\n"
+            "  delay:       The delay of the connection (unit: ms, default 0.).\n")
+        .def_readwrite("source", &arb::cell_connection::source,
+            "The source of the connection.")
+        .def_readwrite("destination", &arb::cell_connection::dest,
+            "The destination of the connection.")
+        .def_readwrite("weight", &arb::cell_connection::weight,
+            "The weight of the connection (unit: S⋅cm⁻²).")
+        .def_readwrite("delay", &arb::cell_connection::delay,
+            "The delay time of the connection (unit: ms).")
+        .def("__str__", &connection_string)
+        .def("__repr__", &connection_string);
+
+    // Gap Junction Connections
+    pybind11::class_<arb::gap_junction_connection> gap_junction_connection(m, "gap_junction_connection",
+        "Describes a gap junction between two gap junction sites.");
+    gap_junction_connection
+        .def(pybind11::init<>(
+            [](){return arb::gap_junction_connection({0u,0u}, {0u,0u}, 0.f);}),
+            "Construct a gap junction connection with default arguments:\n"
+            "  local: gid 0, index 0.\n"
+            "  peer:  gid 0, index 0.\n"
+            "  ggap:  0 μS.\n")
+        .def(pybind11::init<arb::cell_member_type, arb::cell_member_type, double>(),
+            "local"_a, "peer"_a, "ggap"_a = 0.f,
+            "Construct a gap junction connection with arguments:\n"
+            "  local: One half of the gap junction connection.\n"
+            "  peer:  Other half of the gap junction connection.\n"
+            "  ggap:  Gap junction conductance (unit: μS, default 0.).\n")
+        .def_readwrite("local", &arb::gap_junction_connection::local,
+            "One half of the gap junction connection.")
+        .def_readwrite("peer", &arb::gap_junction_connection::peer,
+            "Other half of the gap junction connection.")
+        .def_readwrite("ggap", &arb::gap_junction_connection::ggap,
+            "Gap junction conductance (unit: μS).")
+        .def("__str__", &gap_junction_connection_string)
+        .def("__repr__", &gap_junction_connection_string);
+
+    // Recipes
+    pybind11::class_<py_recipe,
+                     py_recipe_trampoline,
+                     std::shared_ptr<py_recipe>>
+        recipe(m, "recipe", pybind11::dynamic_attr(),
+        "A description of a model, describing the cells and the network via a cell-centric interface.");
+    recipe
+        .def(pybind11::init<>())
+        .def("num_cells", &py_recipe::num_cells, "The number of cells in the model.")
+        .def("cell_description", &py_recipe::cell_description, pybind11::return_value_policy::copy,
+            "gid"_a,
+            "High level description of the cell with global identifier gid.")
+        .def("cell_kind", &py_recipe::cell_kind,
+            "gid"_a,
+            "The kind of cell with global identifier gid.")
+        .def("num_sources", &py_recipe::num_sources,
+            "gid"_a,
+            "The number of spike sources on gid (default 0).")
+        .def("num_targets", &py_recipe::num_targets,
+            "gid"_a,
+            "The number of post-synaptic sites on gid (default 0).")
+        // TODO: py_recipe::num_probes
+        .def("num_gap_junction_sites", &py_recipe::num_gap_junction_sites,
+            "gid"_a,
+            "The number of gap junction sites on gid (default 0).")
+        .def("event_generators", &py_recipe::event_generators,
+            "gid"_a,
+            "A list of all the event generators that are attached to gid (default []).")
+        .def("connections_on", &py_recipe::connections_on,
+            "gid"_a,
+            "A list of all the incoming connections to gid (default []).")
+        .def("gap_junctions_on", &py_recipe::gap_junctions_on,
+            "gid"_a,
+            "A list of the gap junctions connected to gid (default []).")
+        // TODO: py_recipe::get_probe
+        .def("global_properties", &py_recipe::global_properties, pybind11::return_value_policy::copy,
+            "cell_kind"_a,
+            "Global property type specific to a given cell kind.")
+        .def("__str__", [](const py_recipe&){return "<pyarb.recipe>";})
+        .def("__repr__", [](const py_recipe&){return "<pyarb.recipe>";});
+}
+} // namespace pyarb
diff --git a/python/recipe.hpp b/python/recipe.hpp
new file mode 100644
index 00000000..4e83c276
--- /dev/null
+++ b/python/recipe.hpp
@@ -0,0 +1,163 @@
+#pragma once
+
+#include <vector>
+
+#include <pybind11/pybind11.h>
+#include <pybind11/pytypes.h>
+#include <pybind11/stl.h>
+
+#include <arbor/event_generator.hpp>
+#include <arbor/recipe.hpp>
+
+namespace pyarb {
+
+// pyarb::recipe is the recipe interface used by Python.
+// Calls that return generic types return pybind11::object, to avoid
+// having to wrap some C++ types used by the C++ interface (specifically
+// util::unique_any, util::any, std::unique_ptr, etc.)
+// For example, requests for cell description return pybind11::object, instead
+// of util::unique_any used by the C++ recipe interface.
+// The py_recipe_shim unwraps the python objects, and forwards them
+// to the C++ back end.
+
+class py_recipe {
+public:
+    py_recipe() = default;
+    virtual ~py_recipe() {}
+
+    virtual arb::cell_size_type num_cells() const = 0;
+
+    virtual pybind11::object cell_description(arb::cell_gid_type gid) const = 0;
+    virtual arb::cell_kind cell_kind(arb::cell_gid_type gid) const = 0;
+
+    virtual arb::cell_size_type num_sources(arb::cell_gid_type) const { return 0; }
+    virtual arb::cell_size_type num_targets(arb::cell_gid_type) const { return 0; }
+
+    //TODO: virtual arb::cell_size_type num_probes(arb::cell_gid_type) const { return 0; }
+
+    virtual arb::cell_size_type num_gap_junction_sites(arb::cell_gid_type gid) const {
+        return gap_junctions_on(gid).size();
+    }
+
+    virtual std::vector<pybind11::object> event_generators(arb::cell_gid_type gid) const {
+        auto guard = pybind11::gil_scoped_acquire();
+        return {};
+    }
+
+    virtual std::vector<arb::cell_connection> connections_on(arb::cell_gid_type gid) const { return {}; }
+    virtual std::vector<arb::gap_junction_connection> gap_junctions_on(arb::cell_gid_type) const { return {}; }
+
+    //TODO: virtual pybind11::object get_probe (arb::cell_member_type id) const {...}
+
+    virtual pybind11::object global_properties(arb::cell_kind kind) const = 0;
+};
+
+class py_recipe_trampoline: public py_recipe {
+public:
+    arb::cell_size_type num_cells() const override {
+        PYBIND11_OVERLOAD_PURE(arb::cell_size_type, py_recipe, num_cells);
+    }
+
+    pybind11::object cell_description(arb::cell_gid_type gid) const override {
+        PYBIND11_OVERLOAD_PURE(pybind11::object, py_recipe, cell_description, gid);
+    }
+
+    arb::cell_kind cell_kind(arb::cell_gid_type gid) const override {
+        PYBIND11_OVERLOAD_PURE(arb::cell_kind, py_recipe, cell_kind, gid);
+    }
+    
+    arb::cell_size_type num_sources(arb::cell_gid_type gid) const override {
+        PYBIND11_OVERLOAD(arb::cell_size_type, py_recipe, num_sources, gid);
+    }
+
+    arb::cell_size_type num_targets(arb::cell_gid_type gid) const override {
+        PYBIND11_OVERLOAD(arb::cell_size_type, py_recipe, num_targets, gid);
+    }
+
+    //TODO: arb::cell_size_type num_probes(arb::cell_gid_type)
+
+    arb::cell_size_type num_gap_junction_sites(arb::cell_gid_type gid) const override {
+        PYBIND11_OVERLOAD(arb::cell_size_type, py_recipe, num_gap_junction_sites, gid);
+    }
+
+    std::vector<pybind11::object> event_generators(arb::cell_gid_type gid) const override {
+        PYBIND11_OVERLOAD(std::vector<pybind11::object>, py_recipe, event_generators, gid);
+    }
+
+    std::vector<arb::cell_connection> connections_on(arb::cell_gid_type gid) const override {
+        PYBIND11_OVERLOAD(std::vector<arb::cell_connection>, py_recipe, connections_on, gid);
+    }
+
+    std::vector<arb::gap_junction_connection> gap_junctions_on(arb::cell_gid_type gid) const override {
+        PYBIND11_OVERLOAD(std::vector<arb::gap_junction_connection>, py_recipe, gap_junctions_on, gid);
+    }
+
+    //TODO: pybind11::object get_probe(arb::cell_member_type id)
+
+    pybind11::object global_properties(arb::cell_kind kind) const override {
+        PYBIND11_OVERLOAD_PURE(pybind11::object, py_recipe, global_properties, kind);
+    }
+};
+
+// A recipe shim that holds a pyarb::recipe implementation.
+// Unwraps/translates python-side output from pyarb::recipe and forwards
+// to arb::recipe.
+// For example, unwrap cell descriptions stored in PyObject, and rewrap
+// in util::unique_any.
+
+class py_recipe_shim: public arb::recipe {
+    // pointer to the python recipe implementation
+    std::shared_ptr<py_recipe> impl_;
+
+public:
+    using recipe::recipe;
+
+    py_recipe_shim(std::shared_ptr<py_recipe> r): impl_(std::move(r)) {}
+
+    arb::cell_size_type num_cells() const override {
+        return impl_->num_cells();
+    }
+
+    // The pyarb::recipe::cell_decription returns a pybind11::object, that is
+    // unwrapped and copied into a util::unique_any.
+    arb::util::unique_any get_cell_description(arb::cell_gid_type gid) const override;
+
+    arb::cell_kind get_cell_kind(arb::cell_gid_type gid) const override {
+        return impl_->cell_kind(gid);
+    }
+
+    arb::cell_size_type num_sources(arb::cell_gid_type gid) const override {
+        return impl_->num_sources(gid);
+    }
+
+    arb::cell_size_type num_targets(arb::cell_gid_type gid) const override {
+        return impl_->num_targets(gid);
+    }
+
+/* //TODO: arb::cell_size_type num_probes(arb::cell_gid_type gid) const override {
+        return impl_->num_probes(gid);
+    }
+*/
+    arb::cell_size_type num_gap_junction_sites(arb::cell_gid_type gid) const override {
+        return impl_->num_gap_junction_sites(gid);
+    }
+
+    std::vector<arb::event_generator> event_generators(arb::cell_gid_type gid) const override;
+
+    std::vector<arb::cell_connection> connections_on(arb::cell_gid_type gid) const override {
+        return impl_->connections_on(gid);
+    }
+
+    std::vector<arb::gap_junction_connection> gap_junctions_on(arb::cell_gid_type gid) const override {
+        return impl_->gap_junctions_on(gid);
+    }
+
+    //TODO: arb::probe_info get_probe(arb::cell_member_type id) const override
+
+    // The pyarb::recipe::global_properties returns a pybind11::object, that is
+    // unwrapped and copied into a util::any.
+    arb::util::any get_global_properties(arb::cell_kind kind) const override;
+
+};
+
+} // namespace pyarb
diff --git a/python/test/unit/test_contexts.py b/python/test/unit/test_contexts.py
index e007207f..a445bb15 100644
--- a/python/test/unit/test_contexts.py
+++ b/python/test/unit/test_contexts.py
@@ -42,18 +42,18 @@ class Contexts(unittest.TestCase):
 
     def test_exceptions_allocation(self):
         with self.assertRaisesRegex(RuntimeError,
-            "gpu id must be None, or a non-negative integer."):
+            "gpu_id must be None, or a non-negative integer"):
             arb.proc_allocation(gpu_id = 1.)
         with self.assertRaisesRegex(RuntimeError,
-            "gpu id must be None, or a non-negative integer."):
+            "gpu_id must be None, or a non-negative integer"):
             arb.proc_allocation(gpu_id = -1)
         with self.assertRaisesRegex(RuntimeError,
-            "gpu id must be None, or a non-negative integer."):
+            "gpu_id must be None, or a non-negative integer"):
             arb.proc_allocation(gpu_id = 'gpu_id')
         with self.assertRaises(TypeError):
             arb.proc_allocation(threads = 1.)
         with self.assertRaisesRegex(RuntimeError,
-            "threads must be a positive integer."):
+            "threads must be a positive integer"):
              arb.proc_allocation(threads = 0)
         with self.assertRaises(TypeError):
             arb.proc_allocation(threads = None)
diff --git a/python/test/unit/test_event_generators.py b/python/test/unit/test_event_generators.py
index 5febad17..b64119c8 100644
--- a/python/test/unit/test_event_generators.py
+++ b/python/test/unit/test_event_generators.py
@@ -51,17 +51,17 @@ class RegularSchedule(unittest.TestCase):
 
     def test_exceptions_regular_schedule(self):
         with self.assertRaisesRegex(RuntimeError,
-            "tstart must a non-negative number, or None."):
+            "tstart must a non-negative number, or None"):
             arb.regular_schedule(tstart = -1.)
         with self.assertRaisesRegex(RuntimeError,
-            "dt must be a non-negative number."):
+            "dt must be a non-negative number"):
             arb.regular_schedule(dt = -0.1)
         with self.assertRaises(TypeError):
             arb.regular_schedule(dt = None)
         with self.assertRaises(TypeError):
             arb.regular_schedule(dt = 'dt')
         with self.assertRaisesRegex(RuntimeError,
-            "tstop must a non-negative number, or None."):
+            "tstop must a non-negative number, or None"):
             arb.regular_schedule(tstop = 'tstop')
 
 class ExplicitSchedule(unittest.TestCase):
@@ -86,7 +86,7 @@ class ExplicitSchedule(unittest.TestCase):
 
     def test_exceptions_explicit_schedule(self):
         with self.assertRaisesRegex(RuntimeError,
-            "explicit time schedule can not contain negative values."):
+            "explicit time schedule can not contain negative values"):
             arb.explicit_schedule([-1])
         with self.assertRaises(TypeError):
             arb.explicit_schedule(['times'])
@@ -128,14 +128,14 @@ class PoissonSchedule(unittest.TestCase):
 
     def test_exceptions_poisson_schedule(self):
         with self.assertRaisesRegex(RuntimeError,
-            "tstart must be a non-negative number."):
+            "tstart must be a non-negative number"):
             arb.poisson_schedule(tstart = -10.)
         with self.assertRaises(TypeError):
             arb.poisson_schedule(tstart = None)
         with self.assertRaises(TypeError):
             arb.poisson_schedule(tstart = 'tstart')
         with self.assertRaisesRegex(RuntimeError,
-            "frequency must be a non-negative number."):
+            "frequency must be a non-negative number"):
             arb.poisson_schedule(freq = -100.)
         with self.assertRaises(TypeError):
             arb.poisson_schedule(freq = 'freq')
-- 
GitLab