diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt
index 234597ba6116061e9a3c58e20a15b40190c832df..91b1eb5b938efa41336cec15e2c7fa4e639631ad 100644
--- a/python/CMakeLists.txt
+++ b/python/CMakeLists.txt
@@ -24,6 +24,7 @@ add_library(pyarb MODULE
     mpi.cpp
     pyarb.cpp
     recipe.cpp
+    simulation.cpp
     schedule.cpp
 )
 
diff --git a/python/domain_decomposition.cpp b/python/domain_decomposition.cpp
index 8a52275f3e1255b66650b7edde15061e78630f1f..db0e6dd4ca13513e97ac5066aa10cf18a73993a2 100644
--- a/python/domain_decomposition.cpp
+++ b/python/domain_decomposition.cpp
@@ -28,7 +28,6 @@ std::string dd_string(const arb::domain_decomposition& d) {
       << d.num_domains << ", "
       << d.num_local_cells << "/" << d.num_global_cells << " loc/glob cells, "
       << d.groups.size() << " groups>";
-
     return s.str();
 }
 
diff --git a/python/identifiers.cpp b/python/identifiers.cpp
index e6dda467964962b796bc82c386a8933ef641e503..86888b849baeab061fd11874e862d31d108f8ba3 100644
--- a/python/identifiers.cpp
+++ b/python/identifiers.cpp
@@ -59,6 +59,15 @@ void register_identifiers(pybind11::module& m) {
             "Use GPU backend.")
         .value("multicore", arb::backend_kind::multicore,
             "Use multicore backend.");
+
+    pybind11::enum_<arb::binning_kind>(m, "binning_kind",
+        "Enumeration for event time binning policy.")
+        .value("none", arb::binning_kind::none,
+            "No binning policy.")
+        .value("regular", arb::binning_kind::regular,
+            "Round time down to multiple of binning interval.")
+        .value("following", arb::binning_kind::following,
+            "Round times down to previous event if within binning interval.");
 }
 
 } // namespace pyarb
diff --git a/python/pyarb.cpp b/python/pyarb.cpp
index f037db39886b83fff0254e203d5077302d983bf0..123dcdbe181c8cabcdcdc5a67e9d092c85f25468 100644
--- a/python/pyarb.cpp
+++ b/python/pyarb.cpp
@@ -13,6 +13,7 @@ void register_event_generators(pybind11::module& m);
 void register_identifiers(pybind11::module& m);
 void register_recipe(pybind11::module& m);
 void register_schedules(pybind11::module& m);
+void register_simulation(pybind11::module& m);
 
 #ifdef ARB_MPI_ENABLED
 void register_mpi(pybind11::module& m);
@@ -33,4 +34,5 @@ PYBIND11_MODULE(arbor, m) {
     #endif
     pyarb::register_recipe(m);
     pyarb::register_schedules(m);
+    pyarb::register_simulation(m);
 }
diff --git a/python/simulation.cpp b/python/simulation.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..3b3f473654a05eac24607adcb418659a2519d6b7
--- /dev/null
+++ b/python/simulation.cpp
@@ -0,0 +1,44 @@
+#include <pybind11/pybind11.h>
+
+#include <arbor/simulation.hpp>
+
+#include "context.hpp"
+#include "recipe.hpp"
+
+namespace pyarb {
+
+void register_simulation(pybind11::module& m) {
+    using namespace pybind11::literals;
+
+    // Simulation
+    pybind11::class_<arb::simulation> simulation(m, "simulation",
+        "The executable form of a model.\n"
+        "A simulation is constructed from a recipe, and then used to update and monitor model state.");
+    simulation
+        // A custom constructor that wraps a python recipe with
+        // arb::py_recipe_shim before forwarding it to the arb::recipe constructor.
+        .def(pybind11::init(
+            [](std::shared_ptr<py_recipe>& rec, const arb::domain_decomposition& decomp, const context_shim& ctx) {
+                return new arb::simulation(py_recipe_shim(rec), decomp, ctx.context);
+            }),
+            // Release the python gil, so that callbacks into the python
+            // recipe don't deadlock.
+            pybind11::call_guard<pybind11::gil_scoped_release>(),
+            "Initialize the model described by a recipe, with cells and network distributed\n"
+            "according to the domain decomposition and computational resources described by a context.",
+            "recipe"_a, "domain_decomposition"_a, "context"_a)
+        .def("reset", &arb::simulation::reset,
+            pybind11::call_guard<pybind11::gil_scoped_release>(),
+            "Reset the state of the simulation to its initial state.")
+        .def("run", &arb::simulation::run,
+            pybind11::call_guard<pybind11::gil_scoped_release>(),
+            "Run the simulation from current simulation time to tfinal, with maximum time step size dt.",
+            "tfinal"_a, "dt"_a)
+        .def("set_binning_policy", &arb::simulation::set_binning_policy,
+            "Set event binning policy on all our groups.",
+            "policy"_a, "bin_interval"_a)
+        .def("__str__", [](const arb::simulation&){ return "<arbor.simulation>"; })
+        .def("__repr__", [](const arb::simulation&){ return "<arbor.simulation>"; });
+}
+
+} // namespace pyarb