From 0aafd72d03776e99eeff3a647a6cf970252ec14f Mon Sep 17 00:00:00 2001
From: Ben Cumming <louncharf@gmail.com>
Date: Wed, 16 Nov 2016 15:13:35 +0100
Subject: [PATCH] Feature/gpu validation issue #68 (#84)

Fixes #68
Corresponding feature: #67

* Reproduce the hh-soma validation test on GPU.
* Reproduce the ball and stick model on GPU.
* Reproduce miniapp spike chains.
* Add `cell_group` unit test to the cuda unit tests: builds simple ball and stick model and integrates for 50ms and records how many spikes occur; it is a simple early warning that something is broken, but is no substitute for the validation tests.
* Update the `validate_soma`, `validate_ball_and_stick` and `validate_synapses` validation tests for the GPU backend:
    * refactor individual tests into test runner functions that are templated on lowered cell type;
    * for each of the original validation tests add a cuda (.cu) implementation, and write an additional "backend" field to the validation trace metadata.
* Use a `CPrinter` to generate the same `net_receive` block that is used for the multicore backend.
  Note: this is not efficient, because each read/write requires a cuda memcpy between host and device memory, however it allows us to pass all unit and validation tests. A more efficient GPU-specific implementation is left for later optimization work.
* Make paths to `gtest.h`, `test_common_cells.hpp` etc. in test sources consistent relative paths, and remove the `tests/` directory from the include path.
---
 modcc/cprinter.cpp                            |  27 +--
 modcc/cprinter.hpp                            |   3 +
 modcc/cudaprinter.cpp                         |  68 ++++----
 modcc/textbuffer.cpp                          |   7 +
 modcc/textbuffer.hpp                          |   3 +
 src/backends/fvm_multicore.hpp                |   2 +-
 tests/CMakeLists.txt                          |   3 -
 tests/global_communication/mpi_listener.hpp   |   4 +-
 tests/global_communication/test.cpp           |   2 +-
 .../test_communicator.cpp                     |   2 +-
 .../test_exporter_spike_file.cpp              |   2 +-
 .../test_mpi_gather_all.cpp                   |   4 +-
 tests/modcc/test.hpp                          |   2 +-
 tests/unit/CMakeLists.txt                     |   1 +
 tests/unit/test_cell_group.cu                 |  36 ++++
 tests/unit/test_filter.cpp                    |   4 +-
 tests/validation/CMakeLists.txt               |  35 ++--
 tests/validation/convergence_test.hpp         |   3 +-
 tests/validation/tinyopt.hpp                  |   2 +-
 tests/validation/trace_analysis.cpp           |   2 +-
 tests/validation/trace_analysis.hpp           |   2 +-
 tests/validation/validate.cpp                 |   4 +-
 tests/validation/validate_ball_and_stick.cpp  | 153 +---------------
 tests/validation/validate_ball_and_stick.cu   |  22 +++
 tests/validation/validate_ball_and_stick.hpp  | 165 ++++++++++++++++++
 .../validate_compartment_policy.cpp           |   2 +-
 tests/validation/validate_soma.cpp            |  58 +-----
 tests/validation/validate_soma.cu             |   9 +
 tests/validation/validate_soma.hpp            |  56 ++++++
 tests/validation/validate_synapses.cpp        |  73 +-------
 tests/validation/validate_synapses.cu         |  16 ++
 tests/validation/validate_synapses.hpp        |  70 ++++++++
 32 files changed, 490 insertions(+), 352 deletions(-)
 create mode 100644 tests/unit/test_cell_group.cu
 create mode 100644 tests/validation/validate_ball_and_stick.cu
 create mode 100644 tests/validation/validate_ball_and_stick.hpp
 create mode 100644 tests/validation/validate_soma.cu
 create mode 100644 tests/validation/validate_soma.hpp
 create mode 100644 tests/validation/validate_synapses.cu
 create mode 100644 tests/validation/validate_synapses.hpp

diff --git a/modcc/cprinter.cpp b/modcc/cprinter.cpp
index d6b1e537..3d4b01d4 100644
--- a/modcc/cprinter.cpp
+++ b/modcc/cprinter.cpp
@@ -305,12 +305,9 @@ CPrinter::CPrinter(Module &m, bool o)
     //////////////////////////////////////////////
 
     auto proctest = [] (procedureKind k) {
-        return
-            k == procedureKind::normal
-                 || k == procedureKind::api
-                 || k == procedureKind::net_receive;
+        return is_in(k, {procedureKind::normal, procedureKind::api, procedureKind::net_receive});
     };
-    for(auto &var : m.symbols()) {
+    for(auto const& var: m.symbols()) {
         auto isproc = var.second->kind()==symbolKind::procedure;
         if(isproc )
         {
@@ -461,10 +458,6 @@ void CPrinter::visit(BlockExpression *e) {
             }
         }
         if(names.size()>0) {
-            //for(auto it=names.begin(); it!=names.end(); ++it) {
-            //    text_.add_gutter() << "value_type " << *it;
-            //    text_.end_line("{0};");
-            //}
             text_.add_gutter() << "value_type " << *(names.begin());
             for(auto it=names.begin()+1; it!=names.end(); ++it) {
                 text_ << ", " << *it;
@@ -498,8 +491,9 @@ void CPrinter::visit(IfExpression *e) {
     text_ << "}";
 }
 
+// NOTE: net_receive() is classified as a ProcedureExpression
 void CPrinter::visit(ProcedureExpression *e) {
-    // ------------- print prototype ------------- //
+    // print prototype
     text_.add_gutter() << "void " << e->name() << "(int i_";
     for(auto& arg : e->args()) {
         text_ << ", value_type " << arg->is_argument()->name();
@@ -518,19 +512,18 @@ void CPrinter::visit(ProcedureExpression *e) {
             e->location());
     }
 
+    // print body
     increase_indentation();
-
     e->body()->accept(this);
 
-    // ------------- close up ------------- //
+    // close the function body
     decrease_indentation();
     text_.add_line("}");
     text_.add_line();
-    return;
 }
 
 void CPrinter::visit(APIMethod *e) {
-    // ------------- print prototype ------------- //
+    // print prototype
     text_.add_gutter() << "void " << e->name() << "() override {";
     text_.end_line();
 
@@ -566,7 +559,7 @@ void CPrinter::visit(APIMethod *e) {
             }
         }
 
-        // ------------- get loop dimensions ------------- //
+        // get loop dimensions
         text_.add_line("int n_ = node_index_.size();");
 
         // hand off printing of loops to optimized or unoptimized backend
@@ -578,14 +571,12 @@ void CPrinter::visit(APIMethod *e) {
         }
     }
 
-    // ------------- close up ------------- //
+    // close up the loop body
     text_.add_line("}");
     text_.add_line();
 }
 
 void CPrinter::print_APIMethod_unoptimized(APIMethod* e) {
-    //text_.add_line("START_PROFILE");
-
     // there can not be more than 1 instance of a density channel per grid point,
     // so we can assert that aliasing will not occur.
     if(optimize_) text_.add_line("#pragma ivdep");
diff --git a/modcc/cprinter.hpp b/modcc/cprinter.hpp
index e669c25f..ef47977c 100644
--- a/modcc/cprinter.hpp
+++ b/modcc/cprinter.hpp
@@ -44,6 +44,9 @@ public:
     void decrease_indentation(){
         text_.decrease_indentation();
     }
+    void clear_text() {
+        text_.clear();
+    }
 private:
 
     void print_APIMethod_optimized(APIMethod* e);
diff --git a/modcc/cudaprinter.cpp b/modcc/cudaprinter.cpp
index e5103ddf..175781a2 100644
--- a/modcc/cudaprinter.cpp
+++ b/modcc/cudaprinter.cpp
@@ -1,5 +1,6 @@
 #include <algorithm>
 
+#include "cprinter.hpp" // needed for printing net_receive method
 #include "cudaprinter.hpp"
 #include "lexer.hpp"
 
@@ -114,27 +115,11 @@ CUDAPrinter::CUDAPrinter(Module &m, bool o)
         text_.decrease_indentation();
         text_.add_line("}");
         text_.add_line();
-        /*
-        text_.add_line("__device__");
-        text_.add_line("inline double atomicSub(double* address, double val) {");
-        text_.increase_indentation();
-        text_.add_line("return atomicAdd(address, -val);");
-        text_.decrease_indentation();
-        text_.add_line("}");
-        text_.add_line();
-        text_.add_line("__device__");
-        text_.add_line("inline float atomicSub(float* address, float val) {");
-        text_.increase_indentation();
-        text_.add_line("return atomicAdd(address, -val);");
-        text_.decrease_indentation();
-        text_.add_line("}");
-        text_.add_line();
-        */
 
         // forward declarations of procedures
         for(auto const &var : m.symbols()) {
-            if(   var.second->kind()==symbolKind::procedure
-            && var.second->is_procedure()->kind() == procedureKind::normal)
+            if( var.second->kind()==symbolKind::procedure &&
+                var.second->is_procedure()->kind() == procedureKind::normal)
             {
                 print_procedure_prototype(var.second->is_procedure());
                 text_.end_line(";");
@@ -144,11 +129,10 @@ CUDAPrinter::CUDAPrinter(Module &m, bool o)
 
         // print stubs that call API method kernels that are defined in the
         // kernels::name namespace
-        auto proctest = [] (procedureKind k) {return k == procedureKind::normal
-                                                  || k == procedureKind::api;   };
         for(auto const &var : m.symbols()) {
             if (var.second->kind()==symbolKind::procedure &&
-                proctest(var.second->is_procedure()->kind()))
+                is_in(var.second->is_procedure()->kind(),
+                      {procedureKind::normal, procedureKind::api}))
             {
                 var.second->accept(this);
             }
@@ -428,11 +412,9 @@ CUDAPrinter::CUDAPrinter(Module &m, bool o)
 
     //////////////////////////////////////////////
     //////////////////////////////////////////////
-
-    auto proctest = [] (procedureKind k) {return k == procedureKind::api;};
     for(auto const &var : m.symbols()) {
-        if(   var.second->kind()==symbolKind::procedure
-        && proctest(var.second->is_procedure()->kind()))
+        if( var.second->kind()==symbolKind::procedure && 
+            var.second->is_procedure()->kind()==procedureKind::api)
         {
             auto proc = var.second->is_api_method();
             auto name = proc->name();
@@ -450,6 +432,29 @@ CUDAPrinter::CUDAPrinter(Module &m, bool o)
             text_.add_line("}");
             text_.add_line();
         }
+        else if( var.second->kind()==symbolKind::procedure &&
+                 var.second->is_procedure()->kind()==procedureKind::net_receive)
+        {
+            auto proc = var.second->is_procedure();
+            auto name = proc->name();
+            text_.add_line("void " + name + "(int i_, value_type weight) {");
+            text_.increase_indentation();
+
+            // Print the body of the net_receive block.
+            // Use the same body as would be generated with the cprinter.
+            // This is not omptimal, because each read and write will require
+            // a copy between host and device memory, so we will need a
+            // GPU-specific implementation
+            auto cprinter = CPrinter(*module_);
+            cprinter.clear_text();
+            cprinter.set_gutter(text_.get_gutter());
+            proc->body()->accept(&cprinter);
+            text_ << cprinter.text();
+
+            text_.decrease_indentation();
+            text_.add_line("}");
+            text_.add_line();
+        }
     }
 
     //////////////////////////////////////////////
@@ -648,28 +653,26 @@ void CUDAPrinter::visit(ProcedureExpression *e) {
             e->location());
     }
 
-    // ------------- print prototype ------------- //
+    // print prototype
     print_procedure_prototype(e);
     text_.end_line(" {");
 
-    // ------------- print body ------------- //
+    // print body
     increase_indentation();
 
     text_.add_line("using value_type = T;");
-    text_.add_line("using iarray = I;");
     text_.add_line();
 
     e->body()->accept(this);
 
-    // ------------- close up ------------- //
+    // close up
     decrease_indentation();
     text_.add_line("}");
     text_.add_line();
-    return;
 }
 
 void CUDAPrinter::visit(APIMethod *e) {
-    // ------------- print prototype ------------- //
+    // print prototype
     text_.add_gutter() << "template <typename T, typename I>\n";
     text_.add_line(       "__global__");
     text_.add_gutter() << "void " << e->name()
@@ -702,7 +705,8 @@ void CUDAPrinter::visit(APIMethod *e) {
     text_.add_line("}");
 
     decrease_indentation();
-    text_.add_line("}\n");
+    text_.add_line("}");
+    text_.add_line();
 }
 
 void CUDAPrinter::print_APIMethod_body(APIMethod* e) {
diff --git a/modcc/textbuffer.cpp b/modcc/textbuffer.cpp
index 5c8af1df..658d20c4 100644
--- a/modcc/textbuffer.cpp
+++ b/modcc/textbuffer.cpp
@@ -28,6 +28,9 @@ void TextBuffer::set_gutter(int width) {
     indent_ = width;
     gutter_ = std::string(indent_, ' ');
 }
+int TextBuffer::get_gutter() {
+    return indent_;
+}
 
 void TextBuffer::increase_indentation() {
     indent_ += indentation_width_;
@@ -47,3 +50,7 @@ void TextBuffer::decrease_indentation() {
 std::stringstream& TextBuffer::text() {
     return text_;
 }
+
+void TextBuffer::clear() {
+    text_.str("");
+}
diff --git a/modcc/textbuffer.hpp b/modcc/textbuffer.hpp
index cdb3fad3..55b6da67 100644
--- a/modcc/textbuffer.hpp
+++ b/modcc/textbuffer.hpp
@@ -18,11 +18,14 @@ public:
     std::string str() const;
 
     void set_gutter(int width);
+    int get_gutter();
 
     void increase_indentation();
     void decrease_indentation();
     std::stringstream &text();
 
+    void clear();
+
 private:
 
     int indent_ = 0;
diff --git a/src/backends/fvm_multicore.hpp b/src/backends/fvm_multicore.hpp
index 73a0d988..71a0ba0d 100644
--- a/src/backends/fvm_multicore.hpp
+++ b/src/backends/fvm_multicore.hpp
@@ -132,7 +132,7 @@ struct backend {
     static bool has_mechanism(const std::string& name) { return mech_map_.count(name)>0; }
 
     static std::string name() {
-        return "multicore";
+        return "cpu";
     }
 
 private:
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 5fd34932..7036ee30 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -1,8 +1,5 @@
 # google test framework
 add_library(gtest gtest-all.cpp)
-# tests look for gtest.h here
-include_directories(${CMAKE_CURRENT_SOURCE_DIR})
-
 
 # Unit tests
 add_subdirectory(unit)
diff --git a/tests/global_communication/mpi_listener.hpp b/tests/global_communication/mpi_listener.hpp
index e33aaebb..18d245e4 100644
--- a/tests/global_communication/mpi_listener.hpp
+++ b/tests/global_communication/mpi_listener.hpp
@@ -1,12 +1,12 @@
 #pragma once
 
 #include <cstdio>
-#include <ostream>
+#include <fstream>
 #include <stdexcept>
 
 #include <communication/global_policy.hpp>
 
-#include "gtest.h"
+#include "../gtest.h"
 
 /// A specialized listener desinged for printing test results with MPI.
 ///
diff --git a/tests/global_communication/test.cpp b/tests/global_communication/test.cpp
index 1ddb6d41..fae25029 100644
--- a/tests/global_communication/test.cpp
+++ b/tests/global_communication/test.cpp
@@ -3,7 +3,7 @@
 #include <numeric>
 #include <vector>
 
-#include "gtest.h"
+#include "../gtest.h"
 
 #include "mpi_listener.hpp"
 
diff --git a/tests/global_communication/test_communicator.cpp b/tests/global_communication/test_communicator.cpp
index f79d4e2d..b6bcc034 100644
--- a/tests/global_communication/test_communicator.cpp
+++ b/tests/global_communication/test_communicator.cpp
@@ -1,4 +1,4 @@
-#include "gtest.h"
+#include "../gtest.h"
 
 #include <cstdio>
 #include <fstream>
diff --git a/tests/global_communication/test_exporter_spike_file.cpp b/tests/global_communication/test_exporter_spike_file.cpp
index 4a85b078..027a315b 100644
--- a/tests/global_communication/test_exporter_spike_file.cpp
+++ b/tests/global_communication/test_exporter_spike_file.cpp
@@ -1,4 +1,4 @@
-#include "gtest.h"
+#include "../gtest.h"
 
 #include <cstdio>
 #include <fstream>
diff --git a/tests/global_communication/test_mpi_gather_all.cpp b/tests/global_communication/test_mpi_gather_all.cpp
index b5ecf73f..f67df15a 100644
--- a/tests/global_communication/test_mpi_gather_all.cpp
+++ b/tests/global_communication/test_mpi_gather_all.cpp
@@ -1,7 +1,7 @@
-#include "gtest.h"
-
 #ifdef WITH_MPI
 
+#include "../gtest.h"
+
 #include <cstring>
 #include <vector>
 
diff --git a/tests/modcc/test.hpp b/tests/modcc/test.hpp
index d3de7250..45dced64 100644
--- a/tests/modcc/test.hpp
+++ b/tests/modcc/test.hpp
@@ -1,6 +1,6 @@
 #pragma once
 
-#include <gtest.h>
+#include "../gtest.h"
 
 #include "parser.hpp"
 #include "modccutil.hpp"
diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt
index 2dff2310..75c46c46 100644
--- a/tests/unit/CMakeLists.txt
+++ b/tests/unit/CMakeLists.txt
@@ -1,4 +1,5 @@
 set(TEST_CUDA_SOURCES
+    test_cell_group.cu
     test_matrix.cu
     test_vector.cu
 
diff --git a/tests/unit/test_cell_group.cu b/tests/unit/test_cell_group.cu
new file mode 100644
index 00000000..16bc124d
--- /dev/null
+++ b/tests/unit/test_cell_group.cu
@@ -0,0 +1,36 @@
+#include "../gtest.h"
+
+#include <cell_group.hpp>
+#include <common_types.hpp>
+#include <fvm_multicell.hpp>
+#include <util/rangeutil.hpp>
+
+#include "../test_common_cells.hpp"
+
+using namespace nest::mc;
+
+using fvm_cell =
+    fvm::fvm_multicell<nest::mc::gpu::backend>;
+
+nest::mc::cell make_cell() {
+    using namespace nest::mc;
+
+    cell c = make_cell_ball_and_stick();
+
+    c.add_detector({0, 0}, 0);
+    c.segment(1)->set_compartments(101);
+
+    return c;
+}
+
+TEST(cell_group, test)
+{
+    using cell_group_type = cell_group<fvm_cell>;
+    auto group = cell_group_type{0, util::singleton_view(make_cell())};
+
+    group.advance(50, 0.01);
+
+    // the model is expected to generate 4 spikes as a result of the
+    // fixed stimulus over 50 ms
+    EXPECT_EQ(4u, group.spikes().size());
+}
diff --git a/tests/unit/test_filter.cpp b/tests/unit/test_filter.cpp
index 0d357edd..d8ca4e64 100644
--- a/tests/unit/test_filter.cpp
+++ b/tests/unit/test_filter.cpp
@@ -1,5 +1,3 @@
-#include "gtest.h"
-
 #include <cstring>
 #include <list>
 
@@ -8,6 +6,8 @@
 #include <util/filter.hpp>
 #include <util/transform.hpp>
 
+#include "../gtest.h"
+
 #include "common.hpp"
 
 using namespace nest::mc;
diff --git a/tests/validation/CMakeLists.txt b/tests/validation/CMakeLists.txt
index a5d217b2..d9e16533 100644
--- a/tests/validation/CMakeLists.txt
+++ b/tests/validation/CMakeLists.txt
@@ -13,27 +13,38 @@ set(VALIDATION_SOURCES
     validate.cpp
 )
 
+set(VALIDATION_CUDA_SOURCES
+    # unit tests
+    validate_soma.cu
+    validate_ball_and_stick.cu
+    validate_synapses.cu
+
+    # support code
+    validation_data.cpp
+    trace_analysis.cpp
+
+    # unit test driver
+    validate.cpp
+)
+
 if(VALIDATION_DATA_DIR)
     add_definitions("-DDATADIR=\"${VALIDATION_DATA_DIR}\"")
 endif()
-add_executable(validate.exe ${VALIDATION_SOURCES} ${HEADERS})
 
+add_executable(validate.exe ${VALIDATION_SOURCES})
 set(TARGETS validate.exe)
 
-foreach(target ${TARGETS})
-    target_link_libraries(${target} LINK_PUBLIC nestmc gtest)
+if(WITH_CUDA)
+    cuda_add_executable(validate_cuda.exe ${VALIDATION_CUDA_SOURCES})
+    list(APPEND TARGETS validate_cuda.exe)
+    target_link_libraries(validate_cuda.exe LINK_PUBLIC gpu)
+endif()
 
-    if(WITH_TBB)
-        target_link_libraries(${target} LINK_PUBLIC ${TBB_LIBRARIES})
-    endif()
 
-    if(WITH_CUDA)
-        target_link_libraries(${target} LINK_PUBLIC ${CUDA_LIBRARIES})
-    endif()
+foreach(target ${TARGETS})
+    target_link_libraries(${target} LINK_PUBLIC nestmc gtest)
 
-    if(WITH_UNWIND)
-        target_link_libraries(${target} LINK_PUBLIC ${UNWIND_LIBRARIES})
-    endif()
+    target_link_libraries(${target} LINK_PUBLIC ${EXTERNAL_LIBRARIES})
 
     if(WITH_MPI)
         target_link_libraries(${target} LINK_PUBLIC ${MPI_C_LIBRARIES})
diff --git a/tests/validation/convergence_test.hpp b/tests/validation/convergence_test.hpp
index e536a0f1..0561e7d7 100644
--- a/tests/validation/convergence_test.hpp
+++ b/tests/validation/convergence_test.hpp
@@ -2,10 +2,11 @@
 
 #include <util/filter.hpp>
 #include <util/rangeutil.hpp>
+#include <cell.hpp>
 
 #include <json/json.hpp>
 
-#include "gtest.h"
+#include "../gtest.h"
 
 #include "trace_analysis.hpp"
 #include "validation_data.hpp"
diff --git a/tests/validation/tinyopt.hpp b/tests/validation/tinyopt.hpp
index 85a2e4a7..90eb145f 100644
--- a/tests/validation/tinyopt.hpp
+++ b/tests/validation/tinyopt.hpp
@@ -6,7 +6,7 @@
 #include <stdexcept>
 #include <string>
 
-#include "gtest.h"
+#include "../gtest.h"
 
 #include <communication/global_policy.hpp>
 
diff --git a/tests/validation/trace_analysis.cpp b/tests/validation/trace_analysis.cpp
index 78ef5ada..b83d41a2 100644
--- a/tests/validation/trace_analysis.cpp
+++ b/tests/validation/trace_analysis.cpp
@@ -4,7 +4,7 @@
 
 #include <json/json.hpp>
 
-#include "gtest.h"
+#include "../gtest.h"
 
 #include <math.hpp>
 #include <simple_sampler.hpp>
diff --git a/tests/validation/trace_analysis.hpp b/tests/validation/trace_analysis.hpp
index 493a15f7..8b097623 100644
--- a/tests/validation/trace_analysis.hpp
+++ b/tests/validation/trace_analysis.hpp
@@ -2,7 +2,7 @@
 
 #include <vector>
 
-#include "gtest.h"
+#include "../gtest.h"
 
 #include <simple_sampler.hpp>
 #include <math.hpp>
diff --git a/tests/validation/validate.cpp b/tests/validation/validate.cpp
index 9f071aa2..a2f2f832 100644
--- a/tests/validation/validate.cpp
+++ b/tests/validation/validate.cpp
@@ -4,10 +4,10 @@
 #include <string>
 #include <exception>
 
-#include "gtest.h"
-
 #include <communication/global_policy.hpp>
 
+#include "../gtest.h"
+
 #include "tinyopt.hpp"
 #include "validation_data.hpp"
 
diff --git a/tests/validation/validate_ball_and_stick.cpp b/tests/validation/validate_ball_and_stick.cpp
index 01c9bbaa..0e31bcaa 100644
--- a/tests/validation/validate_ball_and_stick.cpp
+++ b/tests/validation/validate_ball_and_stick.cpp
@@ -1,159 +1,22 @@
-#include <json/json.hpp>
-
-#include <cell.hpp>
-#include <common_types.hpp>
-#include <fvm_multicell.hpp>
-#include <model.hpp>
-#include <recipe.hpp>
-#include <simple_sampler.hpp>
-#include <util/path.hpp>
-
 #include "../gtest.h"
+#include "validate_ball_and_stick.hpp"
 
-#include "../test_common_cells.hpp"
-#include "convergence_test.hpp"
-#include "trace_analysis.hpp"
-#include "validation_data.hpp"
-
-using namespace nest::mc;
-
-template <
-    typename lowered_cell,
-    typename SamplerInfoSeq
->
-void run_ncomp_convergence_test(
-    const char* model_name,
-    const util::path& ref_data_path,
-    const cell& c,
-    SamplerInfoSeq& samplers,
-    float t_end=100.f)
-{
-    auto max_ncomp = g_trace_io.max_ncomp();
-    auto dt = g_trace_io.min_dt();
-
-    nlohmann::json meta = {
-        {"name", "membrane voltage"},
-        {"model", model_name},
-        {"dt", dt},
-        {"sim", "nestmc"},
-        {"units", "mV"}
-    };
-
-    auto exclude = stimulus_ends(c);
-
-    convergence_test_runner<int> R("ncomp", samplers, meta);
-    R.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);
-            }
-        }
-        model<lowered_cell> m(singleton_recipe{c});
-
-        R.run(m, ncomp, t_end, dt, exclude);
-    }
-    R.report();
-    R.assert_all_convergence();
-}
-
-TEST(ball_and_taper, neuron_ref) {
-    using lowered_cell = fvm::fvm_multicell<multicore::backend>;
-
-    cell c = make_cell_ball_and_stick();
-    add_common_voltage_probes(c);
+#include <fvm_multicell.hpp>
 
-    float sample_dt = 0.025f;
-    sampler_info samplers[] = {
-        {"soma.mid", {0u, 0u}, simple_sampler(sample_dt)},
-        {"dend.mid", {0u, 1u}, simple_sampler(sample_dt)},
-        {"dend.end", {0u, 2u}, simple_sampler(sample_dt)}
-    };
+using lowered_cell = nest::mc::fvm::fvm_multicell<nest::mc::multicore::backend>;
 
-    run_ncomp_convergence_test<lowered_cell>(
-        "ball_and_stick",
-        "neuron_ball_and_stick.json",
-        c,
-        samplers);
+TEST(ball_and_stick, neuron_ref) {
+    validate_ball_and_stick<lowered_cell>();
 }
 
 TEST(ball_and_3stick, neuron_ref) {
-    using lowered_cell = fvm::fvm_multicell<multicore::backend>;
-
-    cell c = make_cell_ball_and_3stick();
-    add_common_voltage_probes(c);
-
-    float sample_dt = 0.025f;
-    sampler_info samplers[] = {
-        {"soma.mid",  {0u, 0u}, simple_sampler(sample_dt)},
-        {"dend1.mid", {0u, 1u}, simple_sampler(sample_dt)},
-        {"dend1.end", {0u, 2u}, simple_sampler(sample_dt)},
-        {"dend2.mid", {0u, 3u}, simple_sampler(sample_dt)},
-        {"dend2.end", {0u, 4u}, simple_sampler(sample_dt)},
-        {"dend3.mid", {0u, 5u}, simple_sampler(sample_dt)},
-        {"dend3.end", {0u, 6u}, simple_sampler(sample_dt)}
-    };
-
-    run_ncomp_convergence_test<lowered_cell>(
-        "ball_and_3stick",
-        "neuron_ball_and_3stick.json",
-        c,
-        samplers);
+    validate_ball_and_3stick<lowered_cell>();
 }
 
 TEST(rallpack1, numeric_ref) {
-    using lowered_cell = fvm::fvm_multicell<multicore::backend>;
-
-    cell c = make_cell_simple_cable();
-
-    // three probes: left end, 30% along, right end.
-    c.add_probe({{1, 0.0}, probeKind::membrane_voltage});
-    c.add_probe({{1, 0.3}, probeKind::membrane_voltage});
-    c.add_probe({{1, 1.0}, probeKind::membrane_voltage});
-
-    float sample_dt = 0.025f;
-    sampler_info samplers[] = {
-        {"cable.x0.0", {0u, 0u}, simple_sampler(sample_dt)},
-        {"cable.x0.3", {0u, 1u}, simple_sampler(sample_dt)},
-        {"cable.x1.0", {0u, 2u}, simple_sampler(sample_dt)},
-    };
-
-    run_ncomp_convergence_test<lowered_cell>(
-        "rallpack1",
-        "numeric_rallpack1.json",
-        c,
-        samplers,
-        250.f);
+    validate_rallpack1<lowered_cell>();
 }
 
 TEST(ball_and_squiggle, neuron_ref) {
-    using lowered_cell = fvm::fvm_multicell<multicore::backend>;
-
-    cell c = make_cell_ball_and_squiggle();
-    add_common_voltage_probes(c);
-
-    float sample_dt = 0.025f;
-    sampler_info samplers[] = {
-        {"soma.mid", {0u, 0u}, simple_sampler(sample_dt)},
-        {"dend.mid", {0u, 1u}, simple_sampler(sample_dt)},
-        {"dend.end", {0u, 2u}, simple_sampler(sample_dt)}
-    };
-
-#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<lowered_cell>(
-        "ball_and_squiggle_integrator",
-        "neuron_ball_and_squiggle.json",
-        c,
-        samplers);
+    validate_ball_and_squiggle<lowered_cell>();
 }
diff --git a/tests/validation/validate_ball_and_stick.cu b/tests/validation/validate_ball_and_stick.cu
new file mode 100644
index 00000000..b753b6b5
--- /dev/null
+++ b/tests/validation/validate_ball_and_stick.cu
@@ -0,0 +1,22 @@
+#include "../gtest.h"
+#include "validate_ball_and_stick.hpp"
+
+#include <fvm_multicell.hpp>
+
+using lowered_cell = nest::mc::fvm::fvm_multicell<nest::mc::gpu::backend>;
+
+TEST(ball_and_stick, neuron_ref) {
+    validate_ball_and_stick<lowered_cell>();
+}
+
+TEST(ball_and_3stick, neuron_ref) {
+    validate_ball_and_3stick<lowered_cell>();
+}
+
+TEST(rallpack1, numeric_ref) {
+    validate_rallpack1<lowered_cell>();
+}
+
+TEST(ball_and_squiggle, neuron_ref) {
+    validate_ball_and_squiggle<lowered_cell>();
+}
diff --git a/tests/validation/validate_ball_and_stick.hpp b/tests/validation/validate_ball_and_stick.hpp
new file mode 100644
index 00000000..bed16d4d
--- /dev/null
+++ b/tests/validation/validate_ball_and_stick.hpp
@@ -0,0 +1,165 @@
+#include <json/json.hpp>
+
+#include <cell.hpp>
+#include <common_types.hpp>
+#include <fvm_multicell.hpp>
+#include <model.hpp>
+#include <recipe.hpp>
+#include <simple_sampler.hpp>
+#include <util/path.hpp>
+
+#include "../gtest.h"
+
+#include "../test_common_cells.hpp"
+#include "convergence_test.hpp"
+#include "trace_analysis.hpp"
+#include "validation_data.hpp"
+
+template <
+    typename LoweredCell,
+    typename SamplerInfoSeq
+>
+void run_ncomp_convergence_test(
+    const char* model_name,
+    const nest::mc::util::path& ref_data_path,
+    const nest::mc::cell& c,
+    SamplerInfoSeq& samplers,
+    float t_end=100.f)
+{
+    using namespace nest::mc;
+
+    auto max_ncomp = g_trace_io.max_ncomp();
+    auto dt = g_trace_io.min_dt();
+
+    nlohmann::json meta = {
+        {"name", "membrane voltage"},
+        {"model", model_name},
+        {"dt", dt},
+        {"sim", "nestmc"},
+        {"units", "mV"},
+        {"backend", LoweredCell::backend::name()}
+    };
+
+    auto exclude = stimulus_ends(c);
+
+    convergence_test_runner<int> runner("ncomp", samplers, 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);
+            }
+        }
+        model<LoweredCell> m(singleton_recipe{c});
+
+        runner.run(m, ncomp, t_end, dt, exclude);
+    }
+    runner.report();
+    runner.assert_all_convergence();
+}
+
+
+template <typename LoweredCell>
+void validate_ball_and_stick() {
+    using namespace nest::mc;
+
+    cell c = make_cell_ball_and_stick();
+    add_common_voltage_probes(c);
+
+    float sample_dt = 0.025f;
+    sampler_info samplers[] = {
+        {"soma.mid", {0u, 0u}, simple_sampler(sample_dt)},
+        {"dend.mid", {0u, 1u}, simple_sampler(sample_dt)},
+        {"dend.end", {0u, 2u}, simple_sampler(sample_dt)}
+    };
+
+    run_ncomp_convergence_test<LoweredCell>(
+        "ball_and_stick",
+        "neuron_ball_and_stick.json",
+        c,
+        samplers);
+}
+
+template <typename LoweredCell>
+void validate_ball_and_3stick() {
+    using namespace nest::mc;
+
+    cell c = make_cell_ball_and_3stick();
+    add_common_voltage_probes(c);
+
+    float sample_dt = 0.025f;
+    sampler_info samplers[] = {
+        {"soma.mid",  {0u, 0u}, simple_sampler(sample_dt)},
+        {"dend1.mid", {0u, 1u}, simple_sampler(sample_dt)},
+        {"dend1.end", {0u, 2u}, simple_sampler(sample_dt)},
+        {"dend2.mid", {0u, 3u}, simple_sampler(sample_dt)},
+        {"dend2.end", {0u, 4u}, simple_sampler(sample_dt)},
+        {"dend3.mid", {0u, 5u}, simple_sampler(sample_dt)},
+        {"dend3.end", {0u, 6u}, simple_sampler(sample_dt)}
+    };
+
+    run_ncomp_convergence_test<LoweredCell>(
+        "ball_and_3stick",
+        "neuron_ball_and_3stick.json",
+        c,
+        samplers);
+}
+
+template <typename LoweredCell>
+void validate_rallpack1() {
+    using namespace nest::mc;
+
+    cell c = make_cell_simple_cable();
+
+    // three probes: left end, 30% along, right end.
+    c.add_probe({{1, 0.0}, probeKind::membrane_voltage});
+    c.add_probe({{1, 0.3}, probeKind::membrane_voltage});
+    c.add_probe({{1, 1.0}, probeKind::membrane_voltage});
+
+    float sample_dt = 0.025f;
+    sampler_info samplers[] = {
+        {"cable.x0.0", {0u, 0u}, simple_sampler(sample_dt)},
+        {"cable.x0.3", {0u, 1u}, simple_sampler(sample_dt)},
+        {"cable.x1.0", {0u, 2u}, simple_sampler(sample_dt)},
+    };
+
+    run_ncomp_convergence_test<LoweredCell>(
+        "rallpack1",
+        "numeric_rallpack1.json",
+        c,
+        samplers,
+        250.f);
+}
+
+template <typename LoweredCell>
+void validate_ball_and_squiggle() {
+    using namespace nest::mc;
+
+    cell c = make_cell_ball_and_squiggle();
+    add_common_voltage_probes(c);
+
+    float sample_dt = 0.025f;
+    sampler_info samplers[] = {
+        {"soma.mid", {0u, 0u}, simple_sampler(sample_dt)},
+        {"dend.mid", {0u, 1u}, simple_sampler(sample_dt)},
+        {"dend.end", {0u, 2u}, simple_sampler(sample_dt)}
+    };
+
+#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<LoweredCell>(
+        "ball_and_squiggle_integrator",
+        "neuron_ball_and_squiggle.json",
+        c,
+        samplers);
+}
diff --git a/tests/validation/validate_compartment_policy.cpp b/tests/validation/validate_compartment_policy.cpp
index e03f800c..4848ea12 100644
--- a/tests/validation/validate_compartment_policy.cpp
+++ b/tests/validation/validate_compartment_policy.cpp
@@ -11,7 +11,7 @@
 #include <simple_sampler.hpp>
 #include <util/rangeutil.hpp>
 
-#include "gtest.h"
+#include "../gtest.h"
 
 #include "../test_common_cells.hpp"
 #include "../test_util.hpp"
diff --git a/tests/validation/validate_soma.cpp b/tests/validation/validate_soma.cpp
index f7dcf4dc..e4e58e7b 100644
--- a/tests/validation/validate_soma.cpp
+++ b/tests/validation/validate_soma.cpp
@@ -1,61 +1,9 @@
-#include <fstream>
-#include <utility>
-
-#include <json/json.hpp>
-
-#include <common_types.hpp>
-#include <cell.hpp>
-#include <fvm_multicell.hpp>
-#include <model.hpp>
-#include <recipe.hpp>
-#include <simple_sampler.hpp>
-#include <util/rangeutil.hpp>
+#include "validate_soma.hpp"
 
 #include "../gtest.h"
 
-#include "../test_common_cells.hpp"
-#include "convergence_test.hpp"
-#include "trace_analysis.hpp"
-#include "validation_data.hpp"
-
-using namespace nest::mc;
+using lowered_cell = nest::mc::fvm::fvm_multicell<nest::mc::multicore::backend>;
 
 TEST(soma, numeric_ref) {
-    using lowered_cell = fvm::fvm_multicell<multicore::backend>;
-
-    cell c = make_cell_soma_only();
-    add_common_voltage_probes(c);
-    model<lowered_cell> m(singleton_recipe{c});
-
-    float sample_dt = .025f;
-    sampler_info samplers[] = {{"soma.mid", {0u, 0u}, simple_sampler(sample_dt)}};
-
-    nlohmann::json meta = {
-        {"name", "membrane voltage"},
-        {"model", "soma"},
-        {"sim", "nestmc"},
-        {"units", "mV"}
-    };
-
-    convergence_test_runner<float> R("dt", samplers, meta);
-    R.load_reference_data("numeric_soma.json");
-
-    float t_end = 100.f;
-
-    // use dt = 0.05, 0.025, 0.01, 0.005, 0.0025,  ...
-    double max_oo_dt = std::round(1.0/g_trace_io.min_dt());
-    for (double base = 100; ; base *= 10) {
-        for (double multiple: {5., 2.5, 1.}) {
-            double oo_dt = base/multiple;
-            if (oo_dt>max_oo_dt) goto end;
-
-            m.reset();
-            float dt = float(1./oo_dt);
-            R.run(m, dt, t_end, dt);
-        }
-    }
-end:
-
-    R.report();
-    R.assert_all_convergence();
+    validate_soma<lowered_cell>();
 }
diff --git a/tests/validation/validate_soma.cu b/tests/validation/validate_soma.cu
new file mode 100644
index 00000000..35355ab9
--- /dev/null
+++ b/tests/validation/validate_soma.cu
@@ -0,0 +1,9 @@
+#include "validate_soma.hpp"
+
+#include "../gtest.h"
+
+using lowered_cell = nest::mc::fvm::fvm_multicell<nest::mc::gpu::backend>;
+
+TEST(soma, numeric_ref) {
+    validate_soma<lowered_cell>();
+}
diff --git a/tests/validation/validate_soma.hpp b/tests/validation/validate_soma.hpp
new file mode 100644
index 00000000..52bc7d5e
--- /dev/null
+++ b/tests/validation/validate_soma.hpp
@@ -0,0 +1,56 @@
+#include <json/json.hpp>
+
+#include <common_types.hpp>
+#include <cell.hpp>
+#include <fvm_multicell.hpp>
+#include <model.hpp>
+#include <recipe.hpp>
+#include <simple_sampler.hpp>
+#include <util/rangeutil.hpp>
+
+#include "../test_common_cells.hpp"
+#include "convergence_test.hpp"
+#include "trace_analysis.hpp"
+#include "validation_data.hpp"
+
+template <typename LoweredCell>
+void validate_soma() {
+    using namespace nest::mc;
+
+    cell c = make_cell_soma_only();
+    add_common_voltage_probes(c);
+    model<LoweredCell> model(singleton_recipe{c});
+
+    float sample_dt = .025f;
+    sampler_info samplers[] = {{"soma.mid", {0u, 0u}, simple_sampler(sample_dt)}};
+
+    nlohmann::json meta = {
+        {"name", "membrane voltage"},
+        {"model", "soma"},
+        {"sim", "nestmc"},
+        {"units", "mV"},
+        {"backend", LoweredCell::backend::name()}
+    };
+
+    convergence_test_runner<float> runner("dt", samplers, meta);
+    runner.load_reference_data("numeric_soma.json");
+
+    float t_end = 100.f;
+
+    // use dt = 0.05, 0.025, 0.01, 0.005, 0.0025,  ...
+    double max_oo_dt = std::round(1.0/g_trace_io.min_dt());
+    for (double base = 100; ; base *= 10) {
+        for (double multiple: {5., 2.5, 1.}) {
+            double oo_dt = base/multiple;
+            if (oo_dt>max_oo_dt) goto end;
+
+            model.reset();
+            float dt = float(1./oo_dt);
+            runner.run(model, dt, t_end, dt);
+        }
+    }
+end:
+
+    runner.report();
+    runner.assert_all_convergence();
+}
diff --git a/tests/validation/validate_synapses.cpp b/tests/validation/validate_synapses.cpp
index e90fd494..23413cc9 100644
--- a/tests/validation/validate_synapses.cpp
+++ b/tests/validation/validate_synapses.cpp
@@ -1,81 +1,16 @@
-#include <json/json.hpp>
-
-#include <cell.hpp>
-#include <cell_group.hpp>
 #include <fvm_multicell.hpp>
-#include <model.hpp>
-#include <recipe.hpp>
-#include <simple_sampler.hpp>
-#include <util/path.hpp>
 
 #include "../gtest.h"
+#include "validate_synapses.hpp"
 
-#include "../test_common_cells.hpp"
-#include "convergence_test.hpp"
-#include "trace_analysis.hpp"
-#include "validation_data.hpp"
-
-using namespace nest::mc;
-
-void run_synapse_test(
-    const char* syn_type,
-    const util::path& ref_data_path,
-    float t_end=70.f,
-    float dt=0.001)
-{
-    using lowered_cell = fvm::fvm_multicell<multicore::backend>;
-
-    auto max_ncomp = g_trace_io.max_ncomp();
-    nlohmann::json meta = {
-        {"name", "membrane voltage"},
-        {"model", syn_type},
-        {"sim", "nestmc"},
-        {"units", "mV"}
-    };
-
-    cell c = make_cell_ball_and_stick(false); // no stimuli
-    parameter_list syn_default(syn_type);
-    c.add_synapse({1, 0.5}, syn_default);
-    add_common_voltage_probes(c);
-
-    // injected spike events
-    postsynaptic_spike_event<float> 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 = 0.025f;
-    sampler_info samplers[] = {
-        {"soma.mid", {0u, 0u}, simple_sampler(sample_dt)},
-        {"dend.mid", {0u, 1u}, simple_sampler(sample_dt)},
-        {"dend.end", {0u, 2u}, simple_sampler(sample_dt)}
-    };
-
-    convergence_test_runner<int> R("ncomp", samplers, meta);
-    R.load_reference_data(ref_data_path);
-
-    for (int ncomp = 10; ncomp<max_ncomp; ncomp*=2) {
-        c.cable(1)->set_compartments(ncomp);
-        model<lowered_cell> m(singleton_recipe{c});
-        m.group(0).enqueue_events(synthetic_events);
-
-        R.run(m, ncomp, t_end, dt, exclude);
-    }
-    R.report();
-    R.assert_all_convergence();
-}
+using lowered_cell = nest::mc::fvm::fvm_multicell<nest::mc::multicore::backend>;
 
 TEST(simple_synapse, expsyn_neuron_ref) {
     SCOPED_TRACE("expsyn");
-    run_synapse_test("expsyn", "neuron_simple_exp_synapse.json");
+    run_synapse_test<lowered_cell>("expsyn", "neuron_simple_exp_synapse.json");
 }
 
 TEST(simple_synapse, exp2syn_neuron_ref) {
     SCOPED_TRACE("exp2syn");
-    run_synapse_test("exp2syn", "neuron_simple_exp2_synapse.json");
+    run_synapse_test<lowered_cell>("exp2syn", "neuron_simple_exp2_synapse.json");
 }
-
diff --git a/tests/validation/validate_synapses.cu b/tests/validation/validate_synapses.cu
new file mode 100644
index 00000000..0dedd584
--- /dev/null
+++ b/tests/validation/validate_synapses.cu
@@ -0,0 +1,16 @@
+#include <fvm_multicell.hpp>
+
+#include "../gtest.h"
+#include "validate_synapses.hpp"
+
+using lowered_cell = nest::mc::fvm::fvm_multicell<nest::mc::gpu::backend>;
+
+TEST(simple_synapse, expsyn_neuron_ref) {
+    SCOPED_TRACE("expsyn");
+    run_synapse_test<lowered_cell>("expsyn", "neuron_simple_exp_synapse.json");
+}
+
+TEST(simple_synapse, exp2syn_neuron_ref) {
+    SCOPED_TRACE("exp2syn");
+    run_synapse_test<lowered_cell>("exp2syn", "neuron_simple_exp2_synapse.json");
+}
diff --git a/tests/validation/validate_synapses.hpp b/tests/validation/validate_synapses.hpp
new file mode 100644
index 00000000..a6ee35e9
--- /dev/null
+++ b/tests/validation/validate_synapses.hpp
@@ -0,0 +1,70 @@
+#include <json/json.hpp>
+
+#include <cell.hpp>
+#include <cell_group.hpp>
+#include <fvm_multicell.hpp>
+#include <model.hpp>
+#include <recipe.hpp>
+#include <simple_sampler.hpp>
+#include <util/path.hpp>
+
+#include "../gtest.h"
+
+#include "../test_common_cells.hpp"
+#include "convergence_test.hpp"
+#include "trace_analysis.hpp"
+#include "validation_data.hpp"
+
+template <typename LoweredCell>
+void run_synapse_test(
+    const char* syn_type,
+    const nest::mc::util::path& ref_data_path,
+    float t_end=70.f,
+    float dt=0.001)
+{
+    using namespace nest::mc;
+
+    auto max_ncomp = g_trace_io.max_ncomp();
+    nlohmann::json meta = {
+        {"name", "membrane voltage"},
+        {"model", syn_type},
+        {"sim", "nestmc"},
+        {"units", "mV"},
+        {"backend", LoweredCell::backend::name()}
+    };
+
+    cell c = make_cell_ball_and_stick(false); // no stimuli
+    parameter_list syn_default(syn_type);
+    c.add_synapse({1, 0.5}, syn_default);
+    add_common_voltage_probes(c);
+
+    // injected spike events
+    postsynaptic_spike_event<float> 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 = 0.025f;
+    sampler_info samplers[] = {
+        {"soma.mid", {0u, 0u}, simple_sampler(sample_dt)},
+        {"dend.mid", {0u, 1u}, simple_sampler(sample_dt)},
+        {"dend.end", {0u, 2u}, simple_sampler(sample_dt)}
+    };
+
+    convergence_test_runner<int> runner("ncomp", samplers, meta);
+    runner.load_reference_data(ref_data_path);
+
+    for (int ncomp = 10; ncomp<max_ncomp; ncomp*=2) {
+        c.cable(1)->set_compartments(ncomp);
+        model<LoweredCell> m(singleton_recipe{c});
+        m.group(0).enqueue_events(synthetic_events);
+
+        runner.run(m, ncomp, t_end, dt, exclude);
+    }
+    runner.report();
+    runner.assert_all_convergence();
+}
-- 
GitLab