diff --git a/CMakeLists.txt b/CMakeLists.txt
index 62c167a698d58d3ed8470ef14d430eebc156e0f1..86bd6f78cf4ba6d419ac0242d088aa54cca6a8cc 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -88,16 +88,34 @@ else()
     set(use_external_modcc ON BOOL)
 endif()
 
-# whether to attempt to use nrniv to build validation data
+# Validation data generation
+
+# destination directory for generated data
+set(VALIDATION_DATA_DIR "${CMAKE_SOURCE_DIR}/validation/data" CACHE PATH "location of generated validation data")
+
+# Whether to build validation data at all
+set(BUILD_VALIDATION_DATA ON CACHE BOOL "generate validation data")
+
+# Whether to attempt to use julia to build validation data
+find_program(JULIA_BIN julia)
+if(JULIA_BIN STREQUAL "JULIA_BIN-NOTFOUND")
+    message(STATUS "julia not found; will not automatically build validation data sets from julia scripts")
+    set(BUILD_JULIA_VALIDATION_DATA FALSE)
+else()
+    set(BUILD_JULIA_VALIDATION_DATA TRUE)
+endif()
+
+# Whether to attempt to use nrniv to build validation data
 # (if we find nrniv, do)
 find_program(NRNIV_BIN nrniv)
 if(NRNIV_BIN STREQUAL "NRNIV_BIN-NOTFOUND")
-    message(STATUS "nrniv not found; will not automatically build validation data sets")
-    set(BUILD_VALIDATION_DATA FALSE)
+    message(STATUS "nrniv not found; will not automatically build NEURON validation data sets")
+    set(BUILD_NRN_VALIDATION_DATA FALSE)
 else()
-    set(BUILD_VALIDATION_DATA TRUE)
+    set(BUILD_NRN_VALIDATION_DATA TRUE)
 endif()
 
+
 include_directories(${CMAKE_SOURCE_DIR}/tclap/include)
 include_directories(${CMAKE_SOURCE_DIR}/vector)
 include_directories(${CMAKE_SOURCE_DIR}/include)
@@ -109,12 +127,17 @@ if( "${WITH_TBB}" STREQUAL "ON" )
     include_directories(${TBB_INCLUDE_DIRS})
 endif()
 
+# only include validation data if flag is set
+if(BUILD_VALIDATION_DATA)
+    add_subdirectory(validation)
+endif()
+
 # only compile modcc if it is not provided externally
 if(use_external_modcc)
     add_subdirectory(modcc)
 endif()
+
 add_subdirectory(mechanisms)
-add_subdirectory(nrn)
 add_subdirectory(src)
 add_subdirectory(tests)
 add_subdirectory(miniapp)
diff --git a/docs/passive_cable/cable_computation.tex b/docs/passive_cable/cable_computation.tex
index 39a372196c006c7c67837f039df88c903cb439b4..888aa8a11890879818e2964ba4644744e434ab79 100644
--- a/docs/passive_cable/cable_computation.tex
+++ b/docs/passive_cable/cable_computation.tex
@@ -54,16 +54,16 @@ is the reversal potential.
 \begin{table}[ht]
     \centering
     \begin{tabular}{lSl}
-	\toprule
-	Term & {Value} & Property\\
-	\midrule
-	$d$    & \SI{1.0}{\um}                 & cable diameter \\
-	$L$    & \SI{1.0}{\mm}                 & cable length \\
-	$R_A$  & \SI{1.0}{\ohm\m}              & bulk axial resistivity \\
-	$R_M$  & \SI{4.0}{\ohm\m\squared}      & areal membrane resistivity \\
-	$C_M$  & \SI{0.01}{\F\per\m\squared}   & areal membrane capacitance \\
-	$E_M$  & \SI{-65.0}{\mV}               & membrane reversal potential \\
-	\bottomrule
+        \toprule
+        Term & {Value} & Property\\
+        \midrule
+        $d$    & \SI{1.0}{\um}                 & cable diameter \\
+        $L$    & \SI{1.0}{\mm}                 & cable length \\
+        $R_A$  & \SI{1.0}{\ohm\m}              & bulk axial resistivity \\
+        $R_M$  & \SI{4.0}{\ohm\m\squared}      & areal membrane resistivity \\
+        $C_M$  & \SI{0.01}{\F\per\m\squared}   & areal membrane capacitance \\
+        $E_M$  & \SI{-65.0}{\mV}               & membrane reversal potential \\
+        \bottomrule
     \end{tabular}
     \caption{Cable properties for the Rallpack 1 model.}
     \label{tbl:rallpack1}
@@ -87,9 +87,9 @@ and the linear membrane capacitance $c$. These determine $\lambda$ and $\tau$ by
 With the model boundary conditions,
 \begin{subequations}
     \begin{align}
-	v(x, 0) &= E, \\
-	\left.\frac{\partial v}{\partial x}\right\vert_{x=0} & = -Ir, \\
-	 \left.\frac{\partial v}{\partial x}\right\vert_{x=L} & = 0,
+        v(x, 0) &= E, \\
+        \left.\frac{\partial v}{\partial x}\right\vert_{x=0} & = -Ir, \\
+         \left.\frac{\partial v}{\partial x}\right\vert_{x=L} & = 0,
     \end{align}
 \end{subequations}
 where $I$ is the injected current and $L$ is the cable length.
@@ -98,18 +98,18 @@ The solution $v(x, t)$ can be expressed in terms of the solution $g(x, t; L)$
 to a normalized version of the cable equation,
 \begin{subequations}
     \begin{align}
-	\label{eq:normcable}
-	\frac{\partial^2 g}{\partial x^2} & =
-	\frac{\partial g}{\partial t} + g,
+        \label{eq:normcable}
+        \frac{\partial^2 g}{\partial x^2} & =
+        \frac{\partial g}{\partial t} + g,
         \\
-	\label{eq:normcableinitial}
-	g(x, 0) &= 0,
-	\\
-	\label{eq:normcableleft}
-	\left.\frac{\partial g}{\partial x}\right\vert_{x=0} & = 1,
-	\\
-	\label{eq:normcableright}
-	\left.\frac{\partial g}{\partial x}\right\vert_{x=L} & = 0
+        \label{eq:normcableinitial}
+        g(x, 0) &= 0,
+        \\
+        \label{eq:normcableleft}
+        \left.\frac{\partial g}{\partial x}\right\vert_{x=0} & = 1,
+        \\
+        \label{eq:normcableright}
+        \left.\frac{\partial g}{\partial x}\right\vert_{x=L} & = 0
     \end{align}
 \end{subequations}
 by
@@ -158,7 +158,7 @@ and thus
 Consequently,
 \begin{equation}
     \begin{aligned}
-	G(x, s) &= \frac{1}{ms}\cdot\frac{e^{mx}+e^{2mL-mx}}{1-e^{2mL}}\\
+        G(x, s) &= \frac{1}{ms}\cdot\frac{e^{mx}+e^{2mL-mx}}{1-e^{2mL}}\\
                 &= - \frac{1}{ms}\cdot\frac{\cosh m(L-x)}{\sinh mL}.
     \end{aligned}
 \end{equation}
@@ -199,10 +199,10 @@ arising from $m=\sqrt{1+s}$, as letting $m=-\sqrt{1+s}$ leaves $G$ unchanged.
 For $|s+1|>\epsilon$,
 \begin{equation}
     \begin{aligned}
-	|s^{3/2}G(x,s)|^2
-	    & \leq (1+\epsilon)^{-1} \left| \frac{\cosh m(L-x)}{\sinh mL} \right|^2
-	\\
-	    & \leq (1+\epsilon)^{-1} (1+|\coth mL|)^2
+        |s^{3/2}G(x,s)|^2
+            & \leq (1+\epsilon)^{-1} \left| \frac{\cosh m(L-x)}{\sinh mL} \right|^2
+        \\
+            & \leq (1+\epsilon)^{-1} (1+|\coth mL|)^2
     \label{eq:gbounds}
     \end{aligned}
 \end{equation}
@@ -218,8 +218,8 @@ Recalling $m=\sqrt{1+s}$, $m$ and $\sinh mL$ are non-zero in
 a neighbourhood of $s=0$, and so the pole is simple and
 \begin{equation}
     \begin{aligned}
-	\Res(G; 0) & = - \frac{1}{m}\cdot\left.\frac{\cosh m(L-x)}{\sinh mL}\right|_{s=0}\\
-		   & = - \frac{\cosh (L-x)}{\sinh L}.
+        \Res(G; 0) & = - \frac{1}{m}\cdot\left.\frac{\cosh m(L-x)}{\sinh mL}\right|_{s=0}\\
+                   & = - \frac{\cosh (L-x)}{\sinh L}.
     \end{aligned}
 \end{equation}
 
@@ -235,9 +235,9 @@ Let $G(x,s)=f(x,s)/h(s)$, where
 Noting that $dm/ds = \frac{1}{2}m^{-1}$,
 \begin{equation}
     \begin{aligned}
-	h'(s) &= \frac{1}{2}m^{-1}\sinh mL + \frac{1}{2}L\cosh mL \\
-	      &= \frac{1}{2}L + \frac{1}{2}L + O(m^2) \quad(m\to 0) \\
-	      &= L + O(s+1) \quad(s\to -1).
+        h'(s) &= \frac{1}{2}m^{-1}\sinh mL + \frac{1}{2}L\cosh mL \\
+              &= \frac{1}{2}L + \frac{1}{2}L + O(m^2) \quad(m\to 0) \\
+              &= L + O(s+1) \quad(s\to -1).
     \label{eq:hprime}
     \end{aligned}
 \end{equation}
@@ -255,11 +255,11 @@ $m_k$ is non-zero for $k\geq 1$ and
 Consequently the pole is simple and
 \begin{equation}
     \begin{aligned}
-	\Res(G; s_k)
-	    & = f(x, s_k)/h'(s_k)\\
-	    & =  -\frac{2}{s_k L}\frac{\cosh m_k(L-x)}{\cosh m_kL} \\
-	    & =  -\frac{2}{s_k L}\frac{\cosh m_kL\cosh m_kx-\sinh m_kL\sinh m_kL}{\cosh m_kL} \\
-	    & =  -\frac{2}{s_k L}\cosh m_k x,
+        \Res(G; s_k)
+            & = f(x, s_k)/h'(s_k)\\
+            & =  -\frac{2}{s_k L}\frac{\cosh m_k(L-x)}{\cosh m_kL} \\
+            & =  -\frac{2}{s_k L}\frac{\cosh m_kL\cosh m_kx-\sinh m_kL\sinh m_kL}{\cosh m_kL} \\
+            & =  -\frac{2}{s_k L}\cosh m_k x,
     \end{aligned}
 \end{equation}
 as $\sinh m_k=0$.
@@ -272,7 +272,7 @@ In terms of $a_k$,
 The series exapnsion for $g(x, t)$ therefore is
 \begin{equation}
     g(x, t) = -\frac{\cosh(L-x)}{\sinh L} + \frac{1}{L}e^{-t}\left\{
-	1+2\sum_{k=1}^\infty \frac{e^{-ta_k^2}}{1+a_k^2}\cos a_k x\right\}.
+        1+2\sum_{k=1}^\infty \frac{e^{-ta_k^2}}{1+a_k^2}\cos a_k x\right\}.
     \label{eq:theg}
 \end{equation}
 
@@ -285,16 +285,16 @@ of stopping criteria for a given tolerance.
 Let $g_n$ be the partial sum
 \begin{equation}
     g_n(x, t) = -\frac{\cosh(L-x)}{\sinh L} + \frac{1}{L}e^{-t}\left\{
-	1+2\sum_{k=1}^n \frac{e^{-ta_k^2}}{1+a_k^2}\cos a_k x\right\}.
+        1+2\sum_{k=1}^n \frac{e^{-ta_k^2}}{1+a_k^2}\cos a_k x\right\}.
 \end{equation}
 so that $g(x, t) =\lim_{n\to\infty} g_n(x,t)$. Let $\bar{g}_n = |g-g_n|$ be the
 residual. The $a_k$ form an increasing sequence, so
 \begin{equation}
     \begin{aligned}
-	\bar{g}_n(x,t)
-	    & \leq \frac{2}{L}e^{-t}\sum_{n+1}^\infty\frac{e^{-ta_k^2}}{1+a_k^2}\\
-	    & \leq \frac{2}{L}e^{-t}\int_{a_n}^\infty \frac{e^{-tu^2}}{1+u^2}\,du\\
-	    & < \frac{2}{L}e^{-t}\int_{a_n}^\infty \frac{e^{-tu^2}}{u^2}\,du.
+        \bar{g}_n(x,t)
+            & \leq \frac{2}{L}e^{-t}\sum_{n+1}^\infty\frac{e^{-ta_k^2}}{1+a_k^2}\\
+            & \leq \frac{2}{L}e^{-t}\int_{a_n}^\infty \frac{e^{-tu^2}}{1+u^2}\,du\\
+            & < \frac{2}{L}e^{-t}\int_{a_n}^\infty \frac{e^{-tu^2}}{u^2}\,du.
     \end{aligned}
     \label{eq:gbar}
 \end{equation}
@@ -313,9 +313,9 @@ For real $\alpha<1$ and $z>0$, \textcite[][Theorem 2.3]{borwein2009} give the up
 Substituting into \eqref{eq:gbar} gives
 \begin{equation}
     \begin{aligned}
-	\bar{g}_n(x,t)
-	    & < \frac{1}{L}e^{-t}\sqrt{t}\,\Gamma(-\frac{1}{2},a_n^2 t) \\
-	    & \leq \frac{1}{L}e^{-t}\sqrt{t}\,(a_n^2 t)^{-\frac{3}{2}}e^{-a_n^2t} \\
+        \bar{g}_n(x,t)
+            & < \frac{1}{L}e^{-t}\sqrt{t}\,\Gamma(-\frac{1}{2},a_n^2 t) \\
+            & \leq \frac{1}{L}e^{-t}\sqrt{t}\,(a_n^2 t)^{-\frac{3}{2}}e^{-a_n^2t} \\
             & = \frac{e^{-t(1+a_n^2)}}{L t a_n^3}.
     \end{aligned}
 \end{equation}
diff --git a/nrn/CMakeLists.txt b/nrn/CMakeLists.txt
deleted file mode 100644
index 28325c091cea5aec8b0666ac46ba6503e82cdc9a..0000000000000000000000000000000000000000
--- a/nrn/CMakeLists.txt
+++ /dev/null
@@ -1,26 +0,0 @@
-# The validation scripts to run (without .py extension)
-
-set(validations
-     ball_and_stick
-     ball_and_3stick
-     ball_and_taper
-     simple_exp_synapse
-     simple_exp2_synapse
-     soma)
-
-# Only try and make validation sets if we can find nrniv
-if(BUILD_VALIDATION_DATA)
-    set(common "${CMAKE_CURRENT_SOURCE_DIR}/nrn_validation.py")
-    foreach(v ${validations})
-	set(out "${CMAKE_SOURCE_DIR}/data/validation/neuron_${v}.json")
-	set(src "${CMAKE_CURRENT_SOURCE_DIR}/${v}.py")
-	add_custom_command(
-	    OUTPUT "${out}"
-	    DEPENDS "${src}" "${common}"
-	    WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
-	    COMMAND ${NRNIV_BIN} -nobanner -python ${src} > ${out})
-        list(APPEND all_neuron_validation "${out}")
-    endforeach()
-    add_custom_target(validation_data DEPENDS ${all_neuron_validation})
-endif()
-
diff --git a/scripts/tsplot b/scripts/tsplot
index 8e89cbda96451680bfc9de8087ba558c0282defc..4280a9dc902cfa738e5eaf8bb7043008d96baae0 100755
--- a/scripts/tsplot
+++ b/scripts/tsplot
@@ -44,6 +44,7 @@ def parse_clargs():
                    help='plot series with same KEYs on the same axes')
     P.add_argument('-s', '--select', metavar='EXPR,...', dest='select',
                    type=lambda s: s.split(','),
+                   action='append',
                    help='select only series matching EXPR')
     P.add_argument('-o', '--output', metavar='FILE', dest='outfile',
                    help='save plot to file FILE')
@@ -212,7 +213,7 @@ def read_json_timeseries(j, axis='time', select=[]):
         meta = dict(j.items() + {'label': key, 'data': None, 'units': units(key)}.items())
         del meta['data']
 
-        if all([run_select(sel, meta) for sel in select]):
+        if not select or any([all([run_select(s, meta) for s in term]) for term in select]):
             ts_list.append(TimeSeries(times, jdata[key], **meta))
 
     return ts_list
@@ -422,7 +423,7 @@ args = parse_clargs()
 tss = []
 axis = args.axis if args.axis else 'time'
 for filename in args.inputs:
-    select = args.select if args.select else []
+    select = args.select
     with open(filename) as f:
         j = json.load(f)
         tss.extend(read_json_timeseries(j, axis, select))
diff --git a/src/math.hpp b/src/math.hpp
index 4d8ee51c103cc74107938135b8094276fde10c47..bf964ba38c07487914ffcf5768b7f39aee63a5d1 100644
--- a/src/math.hpp
+++ b/src/math.hpp
@@ -1,9 +1,9 @@
 #pragma once
 
 #include <cmath>
+#include <limits>
 #include <utility>
 
-
 namespace nest {
 namespace mc {
 namespace math {
@@ -14,6 +14,12 @@ T constexpr pi()
     return T(3.1415926535897932384626433832795);
 }
 
+template <typename T = float>
+T constexpr infinity()
+{
+    return std::numeric_limits<T>::infinity();
+}
+
 template <typename T>
 T constexpr mean(T a, T b)
 {
diff --git a/src/parameter_list.hpp b/src/parameter_list.hpp
index eb3ee8fe2f3c3ce1018bb113c966cb556e12916e..caa752b9489a483e91f718350bd5c456ded0ae66 100644
--- a/src/parameter_list.hpp
+++ b/src/parameter_list.hpp
@@ -155,7 +155,7 @@ namespace mc {
             // r_L is called Ra in Neuron
             //base::add_parameter({"c_m",  10e-6, {0., 1e9}}); // typically 10 nF/mm^2 == 0.01 F/m^2 == 10^-6 F/cm^2
             base::add_parameter({"c_m",   0.01, {0., 1e9}}); // typically 10 nF/mm^2 == 0.01 F/m^2 == 10^-6 F/cm^2
-            base::add_parameter({"r_L", 180.00, {0., 1e9}}); // equivalent to Ra in Neuron : Ohm.cm
+            base::add_parameter({"r_L", 100.00, {0., 1e9}}); // equivalent to Ra in Neuron : Ohm.cm
         }
     };
 
diff --git a/tests/test_common_cells.hpp b/tests/test_common_cells.hpp
index 9e302365dbea18cff2b4f82a7e74905c9143c5ed..c6863b71712960ca28ae0f9e8d08c7b2bff138ef 100644
--- a/tests/test_common_cells.hpp
+++ b/tests/test_common_cells.hpp
@@ -1,4 +1,7 @@
+#include <cmath>
+
 #include <cell.hpp>
+#include <math.hpp>
 #include <parameter_list.hpp>
 
 namespace nest {
@@ -9,8 +12,9 @@ namespace mc {
  *
  * Soma:
  *    diameter: 18.8 µm
- *    mechanisms: membrane, HH
- *    memrane resistance: 123 Ω·cm
+ *    mechanisms: HH (default params)
+ *    bulk resistivitiy: 100 Ω·cm
+ *    capacitance: 0.01 F/m² [default]
  *
  * Stimuli:
  *    soma centre, t=[10 ms, 110 ms), 0.1 nA
@@ -20,7 +24,7 @@ inline cell make_cell_soma_only(bool with_stim = true) {
     cell c;
 
     auto soma = c.add_soma(18.8/2.0);
-    soma->mechanism("membrane").set("r_L", 123);
+    soma->mechanism("membrane").set("r_L", 100);
     soma->add_mechanism(hh_parameters());
 
     if (with_stim) {
@@ -33,15 +37,19 @@ inline cell make_cell_soma_only(bool with_stim = true) {
 /*
  * Create cell with a soma and unbranched dendrite:
  *
+ * Common properties:
+ *    bulk resistivity: 100 Ω·cm
+ *    capacitance: 0.01 F/m² [default]
+ *
  * Soma:
- *    mechanisms: HH
+ *    mechanisms: HH (default params)
  *    diameter: 12.6157 µm
  *
  * Dendrite:
- *    mechanisms: none
+ *    mechanisms: passive (default params)
  *    diameter: 1 µm
  *    length: 200 µm
- *    membrane resistance: 100 Ω·cm
+ *    bulk resistivity: 100 Ω·cm
  *    compartments: 4
  *
  * Stimulus:
@@ -54,10 +62,15 @@ inline cell make_cell_ball_and_stick(bool with_stim = true) {
     auto soma = c.add_soma(12.6157/2.0);
     soma->add_mechanism(hh_parameters());
 
-    auto dendrite = c.add_cable(0, segmentKind::dendrite, 1.0/2, 1.0/2, 200.0);
-    dendrite->add_mechanism(pas_parameters());
-    dendrite->mechanism("membrane").set("r_L", 100);
-    dendrite->set_compartments(4);
+    c.add_cable(0, segmentKind::dendrite, 1.0/2, 1.0/2, 200.0);
+
+    for (auto& seg: c.segments()) {
+        seg->mechanism("membrane").set("r_L", 100);
+        if (seg->is_dendrite()) {
+            seg->add_mechanism(pas_parameters());
+            seg->set_compartments(4);
+        }
+    }
 
     if (with_stim) {
         c.add_stimulus({1,1}, {5., 80., 0.3});
@@ -68,16 +81,20 @@ inline cell make_cell_ball_and_stick(bool with_stim = true) {
 /*
  * Create cell with a soma and unbranched tapered dendrite:
  *
+ * Common properties:
+ *    bulk resistivity: 100 Ω·cm
+ *    capacitance: 0.01 F/m² [default]
+ *
  * Soma:
- *    mechanisms: HH
+ *    mechanisms: HH (default params)
  *    diameter: 12.6157 µm
  *
  * Dendrite:
- *    mechanisms: none
+ *    mechanisms: passive (default params)
  *    diameter proximal: 1 µm
  *    diameter distal: 0.2 µm
  *    length: 200 µm
- *    membrane resistance: 100 Ω·cm
+ *    bulk resistivity: 100 Ω·cm
  *    compartments: 4
  *
  * Stimulus:
@@ -90,10 +107,15 @@ inline cell make_cell_ball_and_taper(bool with_stim = true) {
     auto soma = c.add_soma(12.6157/2.0);
     soma->add_mechanism(hh_parameters());
 
-    auto dendrite = c.add_cable(0, segmentKind::dendrite, 1.0/2, 0.2/2, 200.0);
-    dendrite->add_mechanism(pas_parameters());
-    dendrite->mechanism("membrane").set("r_L", 100);
-    dendrite->set_compartments(4);
+    c.add_cable(0, segmentKind::dendrite, 1.0/2, 0.2/2, 200.0);
+
+    for (auto& seg: c.segments()) {
+        seg->mechanism("membrane").set("r_L", 100);
+        if (seg->is_dendrite()) {
+            seg->add_mechanism(pas_parameters());
+            seg->set_compartments(4);
+        }
+    }
 
     if (with_stim) {
         c.add_stimulus({1,1}, {5., 80., 0.3});
@@ -106,15 +128,18 @@ inline cell make_cell_ball_and_taper(bool with_stim = true) {
  *
  * O----======
  *
+ * Common properties:
+ *    bulk resistivity: 100 Ω·cm
+ *    capacitance: 0.01 F/m² [default]
+ *
  * Soma:
- *    mechanisms: HH
+ *    mechanisms: HH (default params)
  *    diameter: 12.6157 µm
  *
  * Dendrites:
- *    mechanisms: membrane
+ *    mechanisms: passive (default params)
  *    diameter: 1 µm
  *    length: 100 µm
- *    membrane resistance: 100 Ω·cm
  *    compartments: 4
  *
  * Stimulus:
@@ -128,15 +153,14 @@ inline cell make_cell_ball_and_3stick(bool with_stim = true) {
     auto soma = c.add_soma(12.6157/2.0);
     soma->add_mechanism(hh_parameters());
 
-    // add dendrite of length 200 um and diameter 1 um with passive channel
     c.add_cable(0, segmentKind::dendrite, 0.5, 0.5, 100);
     c.add_cable(1, segmentKind::dendrite, 0.5, 0.5, 100);
     c.add_cable(1, segmentKind::dendrite, 0.5, 0.5, 100);
 
     for (auto& seg: c.segments()) {
+        seg->mechanism("membrane").set("r_L", 100);
         if (seg->is_dendrite()) {
             seg->add_mechanism(pas_parameters());
-            seg->mechanism("membrane").set("r_L", 100);
             seg->set_compartments(4);
         }
     }
@@ -148,6 +172,76 @@ inline cell make_cell_ball_and_3stick(bool with_stim = true) {
     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
+ *    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.
+ *
+ * TODO: Set the correct values when parameters are generally
+ * settable! 
+ *
+ * We can't currently change leak parameters
+ * from defaults, so we scale other electrical parameters
+ * proportionally.
+ */
+
+inline cell make_cell_simple_cable(bool with_stim = true) {
+    cell c;
+
+    c.add_soma(0);
+    c.add_cable(0, segmentKind::dendrite, 0.5, 0.5, 1000);
+
+    double r_L  = 100;
+    double c_m  = 0.01;
+    double gbar = 0.000025;
+    double I = 0.1;
+
+    // fudge factor! can't change passive membrane
+    // conductance from gbar0 = 0.001
+
+    double gbar0 = 0.001;
+    double f = gbar/gbar0;
+
+    // scale everything else
+    r_L *= f;
+    c_m /= f;
+    I /= f;
+
+    for (auto& seg: c.segments()) {
+        seg->add_mechanism(pas_parameters());
+        seg->mechanism("membrane").set("r_L", r_L);
+        seg->mechanism("membrane").set("c_m", c_m);
+        // seg->mechanism("pas").set("g", gbar);
+
+        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.add_stimulus({0,0.5}, {0., math::infinity<>(), I});
+    }
+    return c;
+}
+
+
 /*
  * Attach voltage probes at each cable mid-point and end-point,
  * and at soma mid-point.
diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt
index b6a7d811f24a88fdd618ea80e2e2ac17d6e919f5..60ca6ab0572c74a580399364609d16b9c242adaa 100644
--- a/tests/unit/CMakeLists.txt
+++ b/tests/unit/CMakeLists.txt
@@ -12,6 +12,7 @@ set(TEST_SOURCES
     test_cell_group.cpp
     test_lexcmp.cpp
     test_mask_stream.cpp
+    test_math.cpp
     test_matrix.cpp
     test_mechanisms.cpp
     test_nop.cpp
diff --git a/tests/unit/test_math.cpp b/tests/unit/test_math.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..b32bd194cb75b9df1c31c34c0323fdc0b95bce9e
--- /dev/null
+++ b/tests/unit/test_math.cpp
@@ -0,0 +1,36 @@
+#include <cmath>
+#include <limits>
+
+#include "gtest.h"
+#include <math.hpp>
+
+using namespace nest::mc::math;
+
+TEST(math, infinity) {
+    // check values for float, double, long double
+    auto finf = infinity<float>();
+    EXPECT_TRUE((std::is_same<float, decltype(finf)>::value));
+    EXPECT_TRUE(std::isinf(finf));
+    EXPECT_GT(finf, 0.f);
+
+    auto dinf = infinity<double>();
+    EXPECT_TRUE((std::is_same<double, decltype(dinf)>::value));
+    EXPECT_TRUE(std::isinf(dinf));
+    EXPECT_GT(dinf, 0.0);
+
+    auto ldinf = infinity<long double>();
+    EXPECT_TRUE((std::is_same<long double, decltype(ldinf)>::value));
+    EXPECT_TRUE(std::isinf(ldinf));
+    EXPECT_GT(ldinf, 0.0l);
+
+    // check default value promotes correctly (i.e., acts like INFINITY)
+    struct {
+        float f;
+        double d;
+        long double ld;
+    } check = {infinity<>(), infinity<>(), infinity<>()};
+
+    EXPECT_EQ(std::numeric_limits<float>::infinity(), check.f);
+    EXPECT_EQ(std::numeric_limits<double>::infinity(), check.d);
+    EXPECT_EQ(std::numeric_limits<long double>::infinity(), check.ld);
+}
diff --git a/tests/validation/CMakeLists.txt b/tests/validation/CMakeLists.txt
index 5f61ecc32af9c2083cc69f3945bf8bc8d58417db..783ef00321a34313ea36d90d17d9ec3862afa148 100644
--- a/tests/validation/CMakeLists.txt
+++ b/tests/validation/CMakeLists.txt
@@ -12,8 +12,9 @@ set(VALIDATION_SOURCES
     validate.cpp
 )
 
-add_definitions("-DDATADIR=\"${CMAKE_SOURCE_DIR}/data\"")
-
+if(VALIDATION_DATA_DIR)
+    add_definitions("-DDATADIR=\"${VALIDATION_DATA_DIR}\"")
+endif()
 add_executable(validate.exe ${VALIDATION_SOURCES} ${HEADERS})
 
 set(TARGETS validate.exe)
@@ -36,8 +37,8 @@ foreach(target ${TARGETS})
         RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/tests"
     )
 
-    if (BUILD_VALIDATION_DATA)
-	add_dependencies(${target} validation_data)
+    if(BUILD_VALIDATION_DATA)
+        add_dependencies(${target} validation_data)
     endif()
 endforeach()
 
diff --git a/tests/validation/hh_soma.jl b/tests/validation/hh_soma.jl
deleted file mode 100644
index 82e7df8b5f6c2c594b8b35f584cbf41a42e58e6b..0000000000000000000000000000000000000000
--- a/tests/validation/hh_soma.jl
+++ /dev/null
@@ -1,132 +0,0 @@
-using Sundials
-using SIUnits.ShortUnits
-
-c_m = 0.01nF*m^-2
-
-radius = 18.8μm / 2
-surface_area = 4 * pi * radius * radius
-
-gnabar = .12S*cm^-2
-gkbar  = .036S*cm^-2
-gl     = .0003S*cm^-2
-el     = -54.3mV
-#celsius= 6.3
-#q10 = 3^((celsius - 6.3)/10)
-q10 = 1
-
-# define the resting potential for the membrane
-vrest = -65.0mV
-
-# define the resting potentials for ion species
-ena = 115.0mV+vrest
-ek  = -12.0mV+vrest
-eca =  12.5mV*log(2.0/5e-5)
-
-vtrap(x,y) = x/(exp(x/y) - 1.0)
-
-function print()
-    println("q10 ", q10)
-    println("vrest ", vrest)
-    println("ena ", ena)
-    println("ek ", ek)
-    println("eca ", eca)
-end
-
-#"m" sodium activation system
-function m_lims(v)
-    alpha = .1mV^-1 * vtrap(-(v+40mV),10mV)
-    beta =  4 * exp(-(v+65mV)/18mV)
-    sum = alpha + beta
-    mtau = 1ms / (q10*sum)
-    minf = alpha/sum
-    return mtau, minf
-end
-
-#"h" sodium inactivation system
-function h_lims(v)
-    alpha = 0.07*exp(-(v+65mV)/20mV)
-    beta = 1 / (exp(-(v+35mV)/10mV) + 1)
-    sum = alpha + beta
-    htau = 1ms / (q10*sum)
-    hinf = alpha/sum
-    return htau, hinf
-end
-
-#"n" potassium activation system
-function n_lims(v)
-    alpha = .01mV^-1 * vtrap(-(v+55mV),10mV)
-    beta = .125*exp(-(v+65mV)/80mV)
-    sum = alpha + beta
-    ntau = 1ms / (q10*sum)
-    ninf = alpha/sum
-    return ntau, ninf
-end
-
-# v = y[1]
-# m = y[2]
-# h = y[3]
-# n = y[4]
-
-# choose initial conditions for the system such that the gating variables
-# are at steady state for the user-specified voltage v
-function initial_conditions(v)
-    mtau, minf = m_lims(v)
-    htau, hinf = h_lims(v)
-    ntau, ninf = n_lims(v)
-
-    return [Float64(v), minf, hinf, ninf]
-end
-
-# calculate the lhs of the ODE system
-function f(t, y, ydot)
-    # copy variables into helper variable
-    v = y[1]mV
-    m, h, n = y[2], y[3], y[4]
-
-    # calculate current due to ion channels
-    gna = gnabar*m*m*m*h
-    gk = gkbar*n*n*n*n
-    ina = gna*(v - ena)
-    ik = gk*(v - ek)
-    il = gl*(v - el)
-    imembrane = ik + ina + il
-
-    # calculate current due to stimulus
-    #c.add_stimulus({0,0.5}, {10., 100., 0.1});
-    ielectrode = 0.0nA / surface_area
-    if t>=Float64(10ms) && t<Float64(100ms)
-        ielectrode = 0.1nA / surface_area
-    end
-
-    # calculate the total membrane current
-    i = -imembrane + ielectrode
-
-    # calculate the voltage dependent rates for the gating variables
-    mtau, minf = m_lims(v)
-    ntau, ninf = n_lims(v)
-    htau, hinf = h_lims(v)
-
-    # set the derivatives
-    # note tha these are in SI units, which are indicated in comments
-    ydot[1] = Float64(i/c_m)            # V*s^-1
-    ydot[2] = Float64((minf-m)/mtau)    # s^-1
-    ydot[3] = Float64((hinf-h)/htau)    # s^-1
-    ydot[4] = Float64((ninf-n)/ntau)    # s^-1
-
-    return Sundials.CV_SUCCESS
-end
-
-###########################################################
-#   now we actually run the model
-###########################################################
-
-# from 0 to 100 ms in 1000 steps
-t = collect(linspace(0.0, 0.1, 1001));
-
-# these tolerances are as tight as they will go without breaking convergence of the iterative schemes
-y0 = initial_conditions(vrest)
-res = Sundials.cvode(f, y0, t, abstol=1e-6, reltol=5e-10);
-
-#using Plots
-#gr()
-#plot(t, res[:,1])
diff --git a/tests/validation/trace_analysis.cpp b/tests/validation/trace_analysis.cpp
index d641e34bcbad20ea1c64aece9d47f9c17a4856f0..a754cfe228b8893a6d557eb40092cbde8de4dfa5 100644
--- a/tests/validation/trace_analysis.cpp
+++ b/tests/validation/trace_analysis.cpp
@@ -83,7 +83,7 @@ util::optional<trace_peak> peak_delta(const trace_data& a, const trace_data& b)
     auto p = local_maxima(a);
     auto q = local_maxima(b);
 
-    if (p.size()!=q.size()) return util::nothing;
+    if (p.size()!=q.size() || p.empty()) return util::nothing;
 
     auto max_delta = p[0]-q[0];
 
diff --git a/tests/validation/validate_ball_and_stick.cpp b/tests/validation/validate_ball_and_stick.cpp
index 0a57fa840e52e70ea7dcaef4dc14c0f54ee86d93..e01eb4cf9c6f95a2631e1bbf370762967df71487 100644
--- a/tests/validation/validate_ball_and_stick.cpp
+++ b/tests/validation/validate_ball_and_stick.cpp
@@ -21,66 +21,75 @@
 
 using namespace nest::mc;
 
-// TODO: further consolidate common code
+struct sampler_info {
+    const char* label;
+    cell_member_type probe;
+    simple_sampler sampler;
+};
+
+template <
+    typename lowered_cell,
+    typename SamplerInfoSeq
+>
+void run_ncomp_convergence_test(
+    const char* model_name,
+    const char* ref_data_path,
+    const cell& c,
+    SamplerInfoSeq& samplers,
+    float t_end=100.f,
+    float dt=0.001)
+{
+    using nlohmann::json;
+
+    SCOPED_TRACE(model_name);
 
-TEST(ball_and_stick, neuron_ref) {
-    // compare voltages against reference data produced from
-    // nrn/ball_and_stick.py
-
-    using namespace nlohmann;
-
-    using lowered_cell = fvm::fvm_multicell<double, cell_local_size_type>;
     auto& V = g_trace_io;
-
     bool verbose = V.verbose();
     int max_ncomp = V.max_ncomp();
 
-    // load validation data
-    auto ref_data = V.load_traces("neuron_ball_and_stick.json");
-    bool run_validation =
-        ref_data.count("soma.mid") &&
-        ref_data.count("dend.mid") &&
-        ref_data.count("dend.end");
+    auto keys = util::transform_view(samplers,
+        [](const sampler_info& se) { return se.label; });
 
-    EXPECT_TRUE(run_validation);
+    bool run_validation = false;
+    std::map<std::string, trace_data> ref_data;
+    try {
+        ref_data = V.load_traces(ref_data_path);
 
-    // generate test data
-    cell c = make_cell_ball_and_stick();
-    add_common_voltage_probes(c);
+        run_validation = std::all_of(keys.begin(), keys.end(),
+            [&](const char* key) { return ref_data.count(key)>0; });
 
-    float sample_dt = .025;
-    std::pair<const char *, simple_sampler> samplers[] = {
-        {"soma.mid", simple_sampler(sample_dt)},
-        {"dend.mid", simple_sampler(sample_dt)},
-        {"dend.end", simple_sampler(sample_dt)}
-    };
+        EXPECT_TRUE(run_validation);
+    }
+    catch (std::runtime_error&) {
+        ADD_FAILURE() << "failure loading reference data: " << ref_data_path;
+    }
 
     std::map<std::string, std::vector<conv_entry<int>>> conv_results;
 
     for (int ncomp = 10; ncomp<max_ncomp; ncomp*=2) {
-        for (auto& se: samplers) {
-            se.second.reset();
+        for (auto& seg: c.segments()) {
+            if (!seg->is_soma()) {
+                seg->set_compartments(ncomp);
+            }
         }
-        c.cable(1)->set_compartments(ncomp);
         model<lowered_cell> m(singleton_recipe{c});
 
-        // the ball-and-stick-cell (should) have three voltage probes:
-        // centre of soma, centre of dendrite, end of dendrite.
-
-        m.attach_sampler({0u, 0u}, samplers[0].second.sampler<>());
-        m.attach_sampler({0u, 1u}, samplers[1].second.sampler<>());
-        m.attach_sampler({0u, 2u}, samplers[2].second.sampler<>());
+        // reset samplers and attach to probe locations
+        for (auto& se: samplers) {
+            se.sampler.reset();
+            m.attach_sampler(se.probe, se.sampler.template sampler<>());
+        }
 
-        m.run(100, 0.001);
+        m.run(t_end, dt);
 
         for (auto& se: samplers) {
-            std::string key = se.first;
-            const simple_sampler& s = se.second;
+            std::string key = se.label;
+            const simple_sampler& s = se.sampler;
 
             // save trace
             json meta = {
                 {"name", "membrane voltage"},
-                {"model", "ball_and_stick"},
+                {"model", model_name},
                 {"sim", "nestmc"},
                 {"ncomp", ncomp},
                 {"units", "mV"}};
@@ -101,7 +110,7 @@ TEST(ball_and_stick, neuron_ref) {
         report_conv_table(std::cout, conv_results, "ncomp");
     }
 
-    for (auto key: util::transform_view(samplers, util::first)) {
+    for (auto key: keys) {
         SCOPED_TRACE(key);
 
         const auto& results = conv_results[key];
@@ -109,188 +118,92 @@ TEST(ball_and_stick, neuron_ref) {
     }
 }
 
-TEST(ball_and_taper, neuron_ref) {
-    // compare voltages against reference data produced from
-    // nrn/ball_and_taper.py
-
-    using namespace nlohmann;
-
+TEST(ball_and_stick, neuron_ref) {
     using lowered_cell = fvm::fvm_multicell<double, cell_local_size_type>;
-    auto& V = g_trace_io;
-
-    bool verbose = V.verbose();
-    int max_ncomp = V.max_ncomp();
 
-    // load validation data
-    auto ref_data = V.load_traces("neuron_ball_and_taper.json");
-    bool run_validation =
-        ref_data.count("soma.mid") &&
-        ref_data.count("taper.mid") &&
-        ref_data.count("taper.end");
-
-    EXPECT_TRUE(run_validation);
-
-    // generate test data
-    cell c = make_cell_ball_and_taper();
+    cell c = make_cell_ball_and_stick();
     add_common_voltage_probes(c);
 
-    float sample_dt = .025;
-    std::pair<const char *, simple_sampler> samplers[] = {
-        {"soma.mid", simple_sampler(sample_dt)},
-        {"taper.mid", simple_sampler(sample_dt)},
-        {"taper.end", simple_sampler(sample_dt)}
+    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)}
     };
 
-    std::map<std::string, std::vector<conv_entry<int>>> conv_results;
-
-    for (int ncomp = 10; ncomp<max_ncomp; ncomp*=2) {
-        for (auto& se: samplers) {
-            se.second.reset();
-        }
-        c.cable(1)->set_compartments(ncomp);
-        model<lowered_cell> m(singleton_recipe{c});
-
-        // the ball-and-stick-cell (should) have three voltage probes:
-        // centre of soma, centre of dendrite, end of dendrite.
-
-        m.attach_sampler({0u, 0u}, samplers[0].second.sampler<>());
-        m.attach_sampler({0u, 1u}, samplers[1].second.sampler<>());
-        m.attach_sampler({0u, 2u}, samplers[2].second.sampler<>());
-
-        m.run(100, 0.001);
-
-        for (auto& se: samplers) {
-            std::string key = se.first;
-            const simple_sampler& s = se.second;
-
-            // save trace
-            json meta = {
-                {"name", "membrane voltage"},
-                {"model", "ball_and_taper"},
-                {"sim", "nestmc"},
-                {"ncomp", ncomp},
-                {"units", "mV"}};
-
-            V.save_trace(key, s.trace, meta);
-
-            // compute metrics
-            if (run_validation) {
-                double linf = linf_distance(s.trace, ref_data[key]);
-                auto pd = peak_delta(s.trace, ref_data[key]);
+    run_ncomp_convergence_test<lowered_cell>(
+        "ball_and_stick",
+        "neuron_ball_and_stick.json",
+        c,
+        samplers);
+}
 
-                conv_results[key].push_back({key, ncomp, linf, pd});
-            }
-        }
-    }
+TEST(ball_and_taper, neuron_ref) {
+    using lowered_cell = fvm::fvm_multicell<double, cell_local_size_type>;
 
-    if (verbose && run_validation) {
-        report_conv_table(std::cout, conv_results, "ncomp");
-    }
+    cell c = make_cell_ball_and_taper();
+    add_common_voltage_probes(c);
 
-    for (auto key: util::transform_view(samplers, util::first)) {
-        SCOPED_TRACE(key);
+    float sample_dt = 0.025f;
+    sampler_info samplers[] = {
+        {"soma.mid", {0u, 0u}, simple_sampler(sample_dt)},
+        {"taper.mid", {0u, 1u}, simple_sampler(sample_dt)},
+        {"taper.end", {0u, 2u}, simple_sampler(sample_dt)}
+    };
 
-        const auto& results = conv_results[key];
-        assert_convergence(results);
-    }
+    run_ncomp_convergence_test<lowered_cell>(
+        "ball_and_taper",
+        "neuron_ball_and_taper.json",
+        c,
+        samplers);
 }
 
-
 TEST(ball_and_3stick, neuron_ref) {
-    // compare voltages against reference data produced from
-    // nrn/ball_and_3stick.py
-
-    using namespace nlohmann;
-
     using lowered_cell = fvm::fvm_multicell<double, cell_local_size_type>;
-    auto& V = g_trace_io;
-
-    bool verbose = V.verbose();
-    int max_ncomp = V.max_ncomp();
-
-    // load validation data
-    auto ref_data = V.load_traces("neuron_ball_and_3stick.json");
-    bool run_validation =
-        ref_data.count("soma.mid") &&
-        ref_data.count("dend1.mid") &&
-        ref_data.count("dend1.end") &&
-        ref_data.count("dend2.mid") &&
-        ref_data.count("dend2.end") &&
-        ref_data.count("dend3.mid") &&
-        ref_data.count("dend3.end");
-
 
-    EXPECT_TRUE(run_validation);
-
-    // generate test data
     cell c = make_cell_ball_and_3stick();
     add_common_voltage_probes(c);
 
-    float sample_dt = .025;
-    std::pair<const char *, simple_sampler> samplers[] = {
-        {"soma.mid", simple_sampler(sample_dt)},
-        {"dend1.mid", simple_sampler(sample_dt)},
-        {"dend1.end", simple_sampler(sample_dt)},
-        {"dend2.mid", simple_sampler(sample_dt)},
-        {"dend2.end", simple_sampler(sample_dt)},
-        {"dend3.mid", simple_sampler(sample_dt)},
-        {"dend3.end", simple_sampler(sample_dt)}
+    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)}
     };
 
-    std::map<std::string, std::vector<conv_entry<int>>> conv_results;
-
-    for (int ncomp = 10; ncomp<max_ncomp; ncomp*=2) {
-        for (auto& se: samplers) {
-            se.second.reset();
-        }
-        c.cable(1)->set_compartments(ncomp);
-        c.cable(2)->set_compartments(ncomp);
-        c.cable(3)->set_compartments(ncomp);
-        model<lowered_cell> m(singleton_recipe{c});
-
-        // the ball-and-3stick-cell (should) have seven voltage probes:
-        // centre of soma, followed by centre of section, end of section
-        // for each of the three dendrite sections.
-
-        for (unsigned i = 0; i < util::size(samplers); ++i) {
-            m.attach_sampler({0u, i}, samplers[i].second.sampler<>());
-        }
-
-        m.run(100, 0.001);
-
-        for (auto& se: samplers) {
-            std::string key = se.first;
-            const simple_sampler& s = se.second;
-
-            // save trace
-            json meta = {
-                {"name", "membrane voltage"},
-                {"model", "ball_and_3stick"},
-                {"sim", "nestmc"},
-                {"ncomp", ncomp},
-                {"units", "mV"}};
-
-            V.save_trace(key, s.trace, meta);
+    run_ncomp_convergence_test<lowered_cell>(
+        "ball_and_3stick",
+        "neuron_ball_and_3stick.json",
+        c,
+        samplers);
+}
 
-            // compute metrics
-            if (run_validation) {
-                double linf = linf_distance(s.trace, ref_data[key]);
-                auto pd = peak_delta(s.trace, ref_data[key]);
+TEST(rallpack1, numeric_ref) {
+    using lowered_cell = fvm::fvm_multicell<double, cell_local_size_type>;
 
-                conv_results[key].push_back({key, ncomp, linf, pd});
-            }
-        }
-    }
+    cell c = make_cell_simple_cable();
 
-    if (verbose && run_validation) {
-        report_conv_table(std::cout, conv_results, "ncomp");
-    }
+    // 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});
 
-    for (auto key: util::transform_view(samplers, util::first)) {
-        SCOPED_TRACE(key);
+    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)},
+    };
 
-        const auto& results = conv_results[key];
-        assert_convergence(results);
-    }
+    run_ncomp_convergence_test<lowered_cell>(
+        "rallpack1",
+        "numeric_rallpack1.json",
+        c,
+        samplers,
+        250.f);
 }
 
diff --git a/tests/validation/validate_soma.cpp b/tests/validation/validate_soma.cpp
index 26d919b07c10a5966734c0e12dd7a7296e55f139..31eb784add73f5b675e8b2943e8faa453a0afb26 100644
--- a/tests/validation/validate_soma.cpp
+++ b/tests/validation/validate_soma.cpp
@@ -20,7 +20,7 @@
 
 using namespace nest::mc;
 
-TEST(soma, neuron_ref) {
+TEST(soma, numeric_ref) {
     // compare voltages against reference data produced from
     // nrn/ball_and_taper.py
 
@@ -32,10 +32,21 @@ TEST(soma, neuron_ref) {
     bool verbose = V.verbose();
 
     // load validation data
-    auto ref_data = V.load_traces("neuron_soma.json");
+
+    bool run_validation = false;
+    std::map<std::string, trace_data> ref_data;
     const char* key = "soma.mid";
-    bool run_validation = ref_data.count(key);
-    EXPECT_TRUE(run_validation);
+
+    const char* ref_data_path = "numeric_soma.json";
+    try {
+        ref_data = V.load_traces(ref_data_path);
+        run_validation = ref_data.count(key);
+
+        EXPECT_TRUE(run_validation);
+    }
+    catch (std::runtime_error&) {
+        ADD_FAILURE() << "failure loading reference data: " << ref_data_path;
+    }
 
     // generate test data
     cell c = make_cell_soma_only();
diff --git a/tests/validation/validation_data.hpp b/tests/validation/validation_data.hpp
index 70e99a52227b70f9772dd94370ec38fb7301e2d1..254d6eeed77cd1d99c3ba31a924a8db361e7ff21 100644
--- a/tests/validation/validation_data.hpp
+++ b/tests/validation/validation_data.hpp
@@ -70,7 +70,7 @@ public:
     }
 
 private:
-    util::path datadir_ = DATADIR "/validation";
+    util::path datadir_ = DATADIR;
     std::ofstream out_;
     nlohmann::json jtraces_ = nlohmann::json::array();
     bool verbose_flag_ = false;
diff --git a/validation/CMakeLists.txt b/validation/CMakeLists.txt
new file mode 100644
index 0000000000000000000000000000000000000000..99b4bec969a87219693dfe0bbd05591e1acfd6c2
--- /dev/null
+++ b/validation/CMakeLists.txt
@@ -0,0 +1,51 @@
+# Validation data generation
+
+add_custom_target(validation_data)
+
+# Helper function because ffs CMake.
+
+function(make_unique_target_name name path)
+    # Try and make a broadly human readable target name if possible.
+    string(REGEX REPLACE ".*/" "" leaf "${path}")
+    string(REGEX REPLACE "[^a-zA-Z0-9_.+-]" "_" canon "${leaf}")
+
+    # Check against reserved names, of which of course there is no documented list
+    # except in the sodding CMake source code. Seriously. Look at the CMP0037 policy
+    # text for a laugh.
+    if(canon MATCHES "^(all|ALL_BUILD|help|install|INSTALL|preinstall|clean|edit_cache|rebuild_cache|test|RUN_TESTS|package|PACKAGE|package_source|ZERO_CHECK)$")
+        set(canon "${canon}_")
+    endif()
+    while((TARGET "${canon}"))
+        set(canon "${canon}_")
+    endwhile()
+    set("${name}" "${canon}" PARENT_SCOPE)
+endfunction()
+
+# Helper function to add a data generation script that writes to standard output.
+# e.g.:
+#     add_validation_data(OUTPUT foo_model.json DEPENDS foo_model.py common.py COMMAND python foo_model.py)
+
+include(CMakeParseArguments)
+function(add_validation_data)
+    cmake_parse_arguments(ADD_VALIDATION_DATA "" "OUTPUT" "DEPENDS;COMMAND" ${ARGN})
+    set(out "${VALIDATION_DATA_DIR}/${ADD_VALIDATION_DATA_OUTPUT}")
+    string(REGEX REPLACE "([^;]+)" "${CMAKE_CURRENT_SOURCE_DIR}/\\1" deps "${ADD_VALIDATION_DATA_DEPENDS}")
+    add_custom_command(
+        OUTPUT "${out}"
+        DEPENDS ${deps}
+        WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
+        COMMAND ${ADD_VALIDATION_DATA_COMMAND} > "${out}")
+
+    # Cmake, why can't we just write add_dependencies(validation_data "${out}")?!
+    make_unique_target_name(ffs_cmake "${out}")
+    add_custom_target("${ffs_cmake}" DEPENDS "${out}")
+    add_dependencies(validation_data "${ffs_cmake}")
+endfunction()
+
+
+if(BUILD_NRN_VALIDATION_DATA)
+    add_subdirectory(ref/neuron)
+endif()
+
+add_subdirectory(ref/numeric)
+
diff --git a/validation/README.md b/validation/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..2357501d57fbe0101a05b1ef077c60eafde22a16
--- /dev/null
+++ b/validation/README.md
@@ -0,0 +1,22 @@
+# Validation data and generation
+
+## Sub-directory organization
+
+`validation/data`
+ ~ Generated validation data
+
+`validation/ref`
+ ~ Reference models
+
+`validation/ref/neuron`
+ ~ NEURON-based reference models, run with `nrniv -python`
+
+`validation/ref/numeric`
+ ~ Direct numerical and analytic models
+
+## Data generation
+
+Data is generated via the `validation_data` CMake target, which is
+a prerequisite for the `validation.exe` test executable.
+
+
diff --git a/data/validation/.keep b/validation/data/.keep
similarity index 100%
rename from data/validation/.keep
rename to validation/data/.keep
diff --git a/validation/ref/neuron/CMakeLists.txt b/validation/ref/neuron/CMakeLists.txt
new file mode 100644
index 0000000000000000000000000000000000000000..4df17dc38d23b59c935dad9e02ca0eb19d2b793f
--- /dev/null
+++ b/validation/ref/neuron/CMakeLists.txt
@@ -0,0 +1,18 @@
+# note: function add_validation_data defined in validation/CMakeLists.txt
+
+set(models
+     ball_and_stick
+     ball_and_3stick
+     ball_and_taper
+     simple_exp_synapse
+     simple_exp2_synapse
+     soma)
+
+foreach(model ${models})
+    set(script "${model}.py")
+    add_validation_data(
+        OUTPUT "neuron_${model}.json"
+        DEPENDS "${script}" "nrn_validation.py"
+        COMMAND ${NRNIV_BIN} -nobanner -python "${script}")
+endforeach()
+
diff --git a/nrn/ball_and_3stick.py b/validation/ref/neuron/ball_and_3stick.py
similarity index 100%
rename from nrn/ball_and_3stick.py
rename to validation/ref/neuron/ball_and_3stick.py
diff --git a/nrn/ball_and_stick.py b/validation/ref/neuron/ball_and_stick.py
similarity index 100%
rename from nrn/ball_and_stick.py
rename to validation/ref/neuron/ball_and_stick.py
diff --git a/nrn/ball_and_taper.py b/validation/ref/neuron/ball_and_taper.py
similarity index 100%
rename from nrn/ball_and_taper.py
rename to validation/ref/neuron/ball_and_taper.py
diff --git a/nrn/generate_validation.sh b/validation/ref/neuron/generate_validation.sh
similarity index 100%
rename from nrn/generate_validation.sh
rename to validation/ref/neuron/generate_validation.sh
diff --git a/nrn/nrn_validation.py b/validation/ref/neuron/nrn_validation.py
similarity index 99%
rename from nrn/nrn_validation.py
rename to validation/ref/neuron/nrn_validation.py
index 66563885b05ba9212bd017ea1c90b514c93c6ff2..a5ffa2d2e238148d68dee137dd92daa17ce89d89 100644
--- a/nrn/nrn_validation.py
+++ b/validation/ref/neuron/nrn_validation.py
@@ -33,7 +33,7 @@ default_model_parameters = {
     'g_pas':      0.001,  # Passive conductance in S/cm^2
     'e_pas':    -65.0,    # Leak reversal potential in mV
     'Ra':       100.0,    # Intracellular resistivity in Ω·cm
-    'cm':         1.0,    # Membrane areal capacitance in µF/cm2
+    'cm':         1.0,    # Membrane areal capacitance in µF/cm^2
     'tau':        2.0,    # Exponential synapse time constant
     'tau1':       0.5,    # Exp2 synapse tau1
     'tau2':       2.0,    # Exp2 synapse tau2
diff --git a/nrn/simple_exp2_synapse.py b/validation/ref/neuron/simple_exp2_synapse.py
similarity index 100%
rename from nrn/simple_exp2_synapse.py
rename to validation/ref/neuron/simple_exp2_synapse.py
diff --git a/nrn/simple_exp_synapse.py b/validation/ref/neuron/simple_exp_synapse.py
similarity index 100%
rename from nrn/simple_exp_synapse.py
rename to validation/ref/neuron/simple_exp_synapse.py
diff --git a/nrn/soma.py b/validation/ref/neuron/soma.py
similarity index 75%
rename from nrn/soma.py
rename to validation/ref/neuron/soma.py
index 867fa7cc7b82e2dabb28d4caa03ca88ca10be3f2..706af0f559a3172284bb45d9ca503e47a9f63cc2 100644
--- a/nrn/soma.py
+++ b/validation/ref/neuron/soma.py
@@ -6,11 +6,9 @@ import nrn_validation as V
 
 V.override_defaults_from_args()
 
-# dendrite geometry: all 100 µm long, 1 µm diameter.
-geom = [(0,1), (100, 1)]
-
 model = V.VModel()
-model.add_soma(18.8, Ra=123)
+
+model.add_soma(18.8, Ra=100)
 model.add_iclamp(10, 100, 0.1)
 
 # NB: this doesn't seem to have converged with
diff --git a/validation/ref/numeric/CMakeLists.txt b/validation/ref/numeric/CMakeLists.txt
new file mode 100644
index 0000000000000000000000000000000000000000..ddb5001fb9888b6a207043219db6a9bd11de2c69
--- /dev/null
+++ b/validation/ref/numeric/CMakeLists.txt
@@ -0,0 +1,13 @@
+# note: function add_validation_data defined in validation/CMakeLists.txt
+
+if(BUILD_JULIA_VALIDATION_DATA)
+    add_validation_data(
+        OUTPUT numeric_soma.json
+        DEPENDS numeric_soma.jl HHChannels.jl
+        COMMAND ${JULIA_BIN} numeric_soma.jl)
+
+    add_validation_data(
+        OUTPUT numeric_rallpack1.json
+        DEPENDS numeric_rallpack1.jl PassiveCable.jl
+        COMMAND ${JULIA_BIN} numeric_rallpack1.jl)
+endif()
diff --git a/validation/ref/numeric/HHChannels.jl b/validation/ref/numeric/HHChannels.jl
new file mode 100644
index 0000000000000000000000000000000000000000..bf32c8e84ddafb0f32f23c94086ab23c1408d376
--- /dev/null
+++ b/validation/ref/numeric/HHChannels.jl
@@ -0,0 +1,143 @@
+module HHChannels
+
+export Stim, run_hh
+
+using Sundials
+using SIUnits.ShortUnits
+
+immutable HHParam
+    c_m       # membrane spacific capacitance
+    gnabar    # Na channel cross-membrane conductivity
+    gkbar     # K channel cross-membrane conductivity
+    gl        # Leak conductivity
+    ena       # Na channel reversal potential
+    ek        # K channel reversal potential
+    el        # Leak reversal potential
+    q10       # temperature dependent rate coefficient
+              # (= 3^((T-T₀)/10K) with T₀ = 6.3 °C)
+
+    # constructor with default values, corresponding
+    # to a resting potential of -65 mV and temperature 6.3 °C
+    HHParam(;
+        #c_m    = 0.01nF*m^-2,
+        c_m    = 0.01F*m^-2,
+        gnabar = .12S*cm^-2,
+        ena    = 115.0mV + -65.0mV,
+        gkbar  = .036S*cm^-2,
+        ek     = -12.0mV + -65.0mV,
+        gl     = .0003S*cm^-2,
+        el     = -54.3mV,
+        q10    = 1
+    ) = new(c_m, gnabar, gkbar, gl, ena, ek, el, q10)
+
+end
+
+immutable Stim
+    t0        # start time of stimulus
+    t1        # stop time of stimulus
+    i_e       # stimulus current density
+
+    Stim() = new(0s, 0s, 0A/m^2)
+    Stim(t0, t1, i_e) = new(t0, t1, i_e)
+end
+
+vtrap(x,y) = x/(exp(x/y) - 1.0)
+
+# "m" sodium activation system
+function m_lims(v, q10)
+    alpha = .1mV^-1 * vtrap(-(v+40mV),10mV)
+    beta =  4 * exp(-(v+65mV)/18mV)
+    sum = alpha + beta
+    mtau = 1ms / (q10*sum)
+    minf = alpha/sum
+    return mtau, minf
+end
+
+# "h" sodium inactivation system
+function h_lims(v, q10)
+    alpha = 0.07*exp(-(v+65mV)/20mV)
+    beta = 1 / (exp(-(v+35mV)/10mV) + 1)
+    sum = alpha + beta
+    htau = 1ms / (q10*sum)
+    hinf = alpha/sum
+    return htau, hinf
+end
+
+# "n" potassium activation system
+function n_lims(v, q10)
+    alpha = .01mV^-1 * vtrap(-(v+55mV),10mV)
+    beta = .125*exp(-(v+65mV)/80mV)
+    sum = alpha + beta
+    ntau = 1ms / (q10*sum)
+    ninf = alpha/sum
+    return ntau, ninf
+end
+
+# Choose initial conditions for the system such that the gating variables
+# are at steady state for the user-specified voltage v
+function initial_conditions(v, q10)
+    mtau, minf = m_lims(v, q10)
+    htau, hinf = h_lims(v, q10)
+    ntau, ninf = n_lims(v, q10)
+
+    return (v, minf, hinf, ninf)
+end
+
+# Given time t and state (v, m, h, n),
+# return (vdot, mdot, hdot, ndot)
+function f(t, state; p=HHParam(), stim=Stim())
+    v, m, h, n = state
+
+    # calculate current density due to ion channels
+    gna = p.gnabar*m*m*m*h
+    gk = p.gkbar*n*n*n*n
+
+
+    ina = gna*(v - p.ena)
+    ik = gk*(v - p.ek)
+    il = p.gl*(v - p.el)
+
+    itot = ik + ina + il
+
+    # calculate current density due to stimulus
+    if t>=stim.t0 && t<stim.t1
+        itot -= stim.i_e
+    end
+        
+    # calculate the voltage dependent rates for the gating variables
+    mtau, minf = m_lims(v, p.q10)
+    htau, hinf = h_lims(v, p.q10)
+    ntau, ninf = n_lims(v, p.q10)
+
+    return (-itot/p.c_m, (minf-m)/mtau, (hinf-h)/htau, (ninf-n)/ntau)
+end
+
+function run_hh(t_end; v0=-65mV, stim=Stim(), param=HHParam(), sample_dt=0.01ms)
+    v_scale = 1V
+    t_scale = 1s
+
+    v0, m0, h0, n0 = initial_conditions(v0, param.q10)
+    y0 = [ v0/v_scale, m0, h0, n0 ]
+
+    samples = collect(0s: sample_dt: t_end)
+
+    fbis(t, y, ydot) = begin
+        vdot, mdot, hdot, ndot =
+            f(t*t_scale, (y[1]*v_scale, y[2], y[3], y[4]), stim=stim, p=param)
+
+        ydot[1], ydot[2], ydot[3], ydot[4] =
+            vdot*t_scale/v_scale, mdot*t_scale, hdot*t_scale, ndot*t_scale
+
+        return Sundials.CV_SUCCESS
+    end
+
+    # Ideally would run with vector absolute tolerance to account for v_scale,
+    # but this would prevent us using the nice cvode wrapper.
+
+    res = Sundials.cvode(fbis, y0, map(t->t/t_scale, samples), abstol=1e-6, reltol=5e-10)
+
+    # Use map here because of issues with type deduction with arrays and SIUnits.
+    return samples, map(v->v*v_scale, res[:, 1])
+end
+
+end # module HHChannels
diff --git a/scripts/PassiveCable.jl b/validation/ref/numeric/PassiveCable.jl
similarity index 91%
rename from scripts/PassiveCable.jl
rename to validation/ref/numeric/PassiveCable.jl
index 76450490ce5c4f265403edd35f2bedad4ad7c3bb..6928b74c33948be7162885040cb89870ae245596 100644
--- a/scripts/PassiveCable.jl
+++ b/validation/ref/numeric/PassiveCable.jl
@@ -18,26 +18,29 @@ export cable_normalize, cable, rallpack1
 #
 # Return:
 #     g(x, t)
+#
+# TODO: verify correctness when L≠1
 
-function cable_normalized(x, t, L; tol=1e-8)
+function cable_normalized(x::Float64, t::Float64, L::Float64; tol=1e-8)
     if t<=0
         return 0.0
     else
         ginf = -cosh(L-x)/sinh(L)
         sum = exp(-t/L)
+        Ltol = L*tol
 
         for k = countfrom(1)
             a = k*pi/L
-            e = exp(-t*(1+a^2))
+            b = exp(-t*(1+a^2))
 
-            sum += 2/L*e*cos(a*x)/(1+a^2)
-            resid_ub = e/(L*a^3*t)
+            sum += 2*b*cos(a*x)/(1+a^2)
+            resid_ub = b/(a^3*t)
 
-            if resid_ub<tol
+            if resid_ub<Ltol
                 break
             end
         end
-        return ginf+sum
+        return ginf+sum/L;
      end
 end
 
diff --git a/validation/ref/numeric/numeric_rallpack1.jl b/validation/ref/numeric/numeric_rallpack1.jl
new file mode 100644
index 0000000000000000000000000000000000000000..ea9755aa682ced365d2e22fd6a59cd7456c41682
--- /dev/null
+++ b/validation/ref/numeric/numeric_rallpack1.jl
@@ -0,0 +1,65 @@
+#!/usr/bin/env julia
+
+include("PassiveCable.jl")
+
+using JSON
+using SIUnits.ShortUnits
+using PassiveCable
+
+# This should run the same effective model
+# as rallpack1, but with differing
+# electrical parameters (see below).
+
+function run_cable(x_prop, ts)
+    # Physical properties:
+
+    # f is a fudge factor. rM needs to be the same
+    # the same as in nestmc, where we cannot yet set
+    # the membrane conductance parameter. Scaling
+    # other parameters proportionally, however,
+    # gives the same dynamics.
+
+    f = 0.1/4
+
+    diam =   1.0µm        # cable diameter
+    L    =   1.0mm        # cable length
+    I    =   0.1nA     /f # current injection
+    rL   =   1.0Ω*m    *f # bulk resistivity
+    erev = -65.0mV        # (passive) reversal potential
+    rM   =   4Ω*m^2    *f # membrane resistivity
+    cM   =   0.01F/m^2 /f # membrane specific capacitance
+
+    # convert to linear resistivity, length and time constants
+    area = pi*diam^2/4
+    r    = rL/area
+
+    lambda = sqrt(diam/4 * rM/rL)
+    tau = cM*rM
+
+    # compute solutions
+    tol = 1e-8mV
+    return [cable(L*x_prop, t, L, lambda, tau, r, erev, -I, tol=tol) for t in ts]
+end
+
+function run_rallpack1(x_prop, ts)
+    return [rallpack1(0.001*x_prop, t/s)*V for t in ts]
+end
+
+# Generate traces at x=0, x=0.3L, x=L
+
+ts = collect(0s: 0.025ms: 250ms)
+trace = Dict(
+    :name => "membrane voltage",
+    :sim => "numeric",
+    :model => "rallpack1",
+    :units => "mV",
+    :data => Dict(
+        :time => map(t->t/ms, ts),
+        symbol("cable.x0.0") => map(v->v/mV, run_cable(0, ts)),
+        symbol("cable.x0.3") => map(v->v/mV, run_cable(0.3, ts)),
+        symbol("cable.x1.0") => map(v->v/mV, run_cable(1.0, ts))
+    )
+)
+
+println(JSON.json([trace]))
+
diff --git a/validation/ref/numeric/numeric_soma.jl b/validation/ref/numeric/numeric_soma.jl
new file mode 100644
index 0000000000000000000000000000000000000000..6c431f845cbf5acbb3a1825e8d52951482d255a1
--- /dev/null
+++ b/validation/ref/numeric/numeric_soma.jl
@@ -0,0 +1,27 @@
+#!/usr/bin/env julia
+
+include("HHChannels.jl")
+
+using JSON
+using SIUnits.ShortUnits
+using HHChannels
+
+radius = 18.8µm/2
+area = 4*pi*radius^2
+
+stim = Stim(10ms, 100ms, 0.1nA/area)
+ts, vs = run_hh(100ms, stim=stim, sample_dt=0.025ms)
+
+trace = Dict(
+    :name => "membrane voltage",
+    :sim => "numeric",
+    :model => "soma",
+    :units => "mV",
+    :data => Dict(
+        :time => map(t->t/ms, ts),
+        symbol("soma.mid") => map(v->v/mV, vs)
+    )
+)
+
+println(JSON.json([trace]))
+