diff --git a/arbor/include/arbor/context.hpp b/arbor/include/arbor/context.hpp
index f6c0f1dc7bcb61673232f3d8280c5aee679e33d9..a61461ef65cf1b7a80a97a3bec281e727f4f00b5 100644
--- a/arbor/include/arbor/context.hpp
+++ b/arbor/include/arbor/context.hpp
@@ -17,7 +17,7 @@ struct dry_run_info {
 // By default, a proc_allocation will comprise one thread and no GPU.
 
 struct proc_allocation {
-    unsigned num_threads;
+    unsigned long num_threads;
 
     // The gpu id corresponds to the `int device` parameter used by
     // CUDA/HIP API calls to identify gpu devices.
@@ -27,7 +27,7 @@ struct proc_allocation {
 
     proc_allocation(): proc_allocation(1, -1) {}
 
-    proc_allocation(unsigned threads, int gpu):
+    proc_allocation(unsigned long threads, int gpu):
         num_threads(threads),
         gpu_id(gpu)
     {}
diff --git a/arborenv/CMakeLists.txt b/arborenv/CMakeLists.txt
index 921622c0d1832113afa02131a8c96ca90a306506..16128499391839a27eff81acecd6e1e93eee9af0 100644
--- a/arborenv/CMakeLists.txt
+++ b/arborenv/CMakeLists.txt
@@ -1,8 +1,10 @@
 set(arborenv-sources
+    arbenvexcept.cpp
     affinity.cpp
     concurrency.cpp
-    default_gpu.cpp
+    default_env.cpp
     private_gpu.cpp
+    read_envvar.cpp
 )
 
 if(ARB_WITH_GPU)
diff --git a/arborenv/arbenvexcept.cpp b/arborenv/arbenvexcept.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..2e905a3e17e384c86cc1f5f821b21d4360059956
--- /dev/null
+++ b/arborenv/arbenvexcept.cpp
@@ -0,0 +1,27 @@
+#include <stdexcept>
+#include <string>
+
+#include <arborenv/arbenvexcept.hpp>
+
+using namespace std::literals;
+
+namespace arbenv {
+
+invalid_env_value::invalid_env_value(const std::string& variable, const std::string& value):
+    arborenv_exception("environment variable \""s+variable+"\" has invalid value \""s+value+"\""s),
+    env_variable(variable),
+    env_value(value)
+{}
+
+// GPU enumeration, selection.
+
+no_such_gpu::no_such_gpu(int gpu_id):
+    arborenv_exception("no gpu with id "s+std::to_string(gpu_id)),
+    gpu_id(gpu_id)
+{}
+
+gpu_uuid_error::gpu_uuid_error(std::string what):
+    arborenv_exception("error determining GPU uuids: "s+what)
+{}
+
+} // namespace arbenv
diff --git a/arborenv/concurrency.cpp b/arborenv/concurrency.cpp
index 715c65fce7e56dc8c4c38b0f920d755d4f32e20f..208acee4590bda4543138242ba957817c5c9b505 100644
--- a/arborenv/concurrency.cpp
+++ b/arborenv/concurrency.cpp
@@ -1,6 +1,7 @@
 #include <cstdlib>
-#include <regex>
-#include <string>
+#include <limits>
+#include <optional>
+#include <stdexcept>
 #include <thread>
 
 #include <arborenv/concurrency.hpp>
@@ -11,54 +12,18 @@
 
 namespace arbenv {
 
-// Test environment variables for user-specified count of threads.
-unsigned get_env_num_threads() {
-    using namespace std::literals;
-    const char* str;
-
-    // select variable to use:
-    //   If ARB_NUM_THREADS_VAR is set, use $ARB_NUM_THREADS_VAR
-    //   else if ARB_NUM_THREADS set, use it
-    //   else if OMP_NUM_THREADS set, use it
-    if (auto nthreads_var_name = std::getenv("ARB_NUM_THREADS_VAR")) {
-        str = std::getenv(nthreads_var_name);
-    }
-    else if (! (str = std::getenv("ARB_NUM_THREADS"))) {
-        str = std::getenv("OMP_NUM_THREADS");
-    }
-
-    // No environment variable set, so return 0.
-    if (!str) {
-        return 0;
-    }
-
-    errno = 0;
-    auto nthreads = std::strtoul(str, nullptr, 10);
-
-    // check that the environment variable string describes a non-negative integer
-    if (errno==ERANGE ||
-        !std::regex_match(str, std::regex("\\s*\\d*[0-9]\\d*\\s*")))
-    {
-        errno = 0;
-        throw std::runtime_error("Requested number of threads \""s + str + "\" is not a valid value"s);
-    }
-    errno = 0;
-
-    return nthreads;
-}
-
 // Take a best guess at the number of threads that can be run concurrently.
 // Will return at least 1.
-unsigned thread_concurrency() {
+unsigned long thread_concurrency() {
     // Attempt to get count first from affinity information if available.
-    unsigned n = get_affinity().size();
+    unsigned long n = get_affinity().size();
 
     // If no luck, try sysconf.
 #ifdef _SC_NPROCESSORS_ONLN
     if (!n) {
         long r = sysconf(_SC_NPROCESSORS_ONLN);
         if (r>0) {
-            n = (unsigned)r;
+            n = (unsigned long)r;
         }
     }
 #endif
diff --git a/arborenv/default_env.cpp b/arborenv/default_env.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..1f55acfe45ac45eb0abb869def371639214548a7
--- /dev/null
+++ b/arborenv/default_env.cpp
@@ -0,0 +1,66 @@
+#include <limits>
+#include <optional>
+
+#include <arborenv/arbenvexcept.hpp>
+#include <arborenv/concurrency.hpp>
+#include <arborenv/default_env.hpp>
+
+#ifdef ARB_HAVE_GPU
+#include "gpu_api.hpp"
+#endif
+
+#include "read_envvar.hpp"
+
+namespace arbenv {
+
+unsigned long default_concurrency() {
+    unsigned long env_thread = get_env_num_threads();
+    return env_thread? env_thread: thread_concurrency();
+}
+
+unsigned long get_env_num_threads() {
+    constexpr const char* env_var = "ARBENV_NUM_THREADS";
+    std::optional<long long> env_val = read_env_integer(env_var, throw_on_invalid);
+    if (!env_val) return 0;
+
+    if (*env_val<1 || static_cast<unsigned long long>(*env_val)>std::numeric_limits<unsigned long>::max()) {
+        throw invalid_env_value(env_var, std::getenv(env_var));
+    }
+    return *env_val;
+}
+
+#ifdef ARB_HAVE_GPU
+
+int default_gpu() {
+    constexpr const char* env_var = "ARBENV_GPU_ID";
+    int n_device = -1;
+    get_device_count(&n_device); // error => leave n_device == -1
+
+    std::optional<long long> env_val = read_env_integer(env_var, throw_on_invalid);
+    if (env_val) {
+        if (*env_val<0) return -1;
+        if (env_val > std::numeric_limits<int>::max()) {
+            throw invalid_env_value(env_var, std::getenv(env_var));
+        }
+
+        int id = static_cast<int>(*env_val);
+        if (id>=n_device) {
+            throw arbenv::no_such_gpu(id);
+        }
+
+        return id;
+    }
+
+    return n_device>0? 0: -1;
+}
+
+#else
+
+int default_gpu() {
+    return -1;
+}
+
+#endif // def ARB_HAVE_GPU
+
+} // namespace arbenv
+
diff --git a/arborenv/default_gpu.cpp b/arborenv/default_gpu.cpp
deleted file mode 100644
index 1121b13fdedf6e150df3edc53d329918deafcf0e..0000000000000000000000000000000000000000
--- a/arborenv/default_gpu.cpp
+++ /dev/null
@@ -1,32 +0,0 @@
-#ifdef ARB_HAVE_GPU
-
-#include "gpu_api.hpp"
-
-namespace arbenv {
-
-// When arbor does not have CUDA support, return -1, which always
-// indicates that no GPU is available.
-int default_gpu() {
-    int n;
-    if (get_device_count(&n)) {
-        // if 1 or more GPUs, take the first one.
-        // else return -1 -> no gpu.
-        return n? 0: -1;
-    }
-    return -1;
-}
-
-} // namespace arbenv
-
-#else // ifdef ARB_HAVE_GPU
-
-namespace arbenv {
-
-int default_gpu() {
-    return -1;
-}
-
-} // namespace arbenv
-
-#endif // ifdef ARB_HAVE_GPU
-
diff --git a/arborenv/include/arborenv/arbenvexcept.hpp b/arborenv/include/arborenv/arbenvexcept.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..7b140ccfec61a2a2095642dd502a7a1e484c0996
--- /dev/null
+++ b/arborenv/include/arborenv/arbenvexcept.hpp
@@ -0,0 +1,37 @@
+#pragma once
+
+#include <stdexcept>
+#include <string>
+
+// Arborenv-specific exception hierarchy.
+
+namespace arbenv {
+
+// Common base-class for arborenv run-time errors.
+
+struct arborenv_exception: std::runtime_error {
+    arborenv_exception(const std::string& what_arg):
+        std::runtime_error(what_arg)
+    {}
+};
+
+// Environment variable parsing errors.
+
+struct invalid_env_value: arborenv_exception {
+    invalid_env_value(const std::string& variable, const std::string& value);
+    std::string env_variable;
+    std::string env_value;
+};
+
+// GPU enumeration, selection.
+
+struct no_such_gpu: arborenv_exception {
+    no_such_gpu(int gpu_id);
+    int gpu_id;
+};
+
+struct gpu_uuid_error: arborenv_exception {
+    gpu_uuid_error(std::string what);
+};
+
+} // namespace arbenv
diff --git a/arborenv/include/arborenv/concurrency.hpp b/arborenv/include/arborenv/concurrency.hpp
index 2aa0b7709306f567a1a6c30ba51d93c00a4418de..25b0f7ac2ad1c1eeea11f32291e9097d62ec9d09 100644
--- a/arborenv/include/arborenv/concurrency.hpp
+++ b/arborenv/include/arborenv/concurrency.hpp
@@ -4,27 +4,10 @@
 
 namespace arbenv {
 
-// Test environment variables for user-specified count of threads.
-// Potential environment variables are tested in this order:
-//   1. use the environment variable specified by ARB_NUM_THREADS_VAR
-//   2. use ARB_NUM_THREADS
-//   3. use OMP_NUM_THREADS
-//
-// Valid values for the environment variable are:
-//      0 : Arbor is responsible for picking the number of threads.
-//     >0 : The number of threads to use.
-//
-// Returns:
-//   >0 : the number of threads set by environment variable.
-//    0 : value is not set in environment variable.
-//
-// Throws std::runtime_error:
-//      Environment variable is set with invalid value.
-unsigned get_env_num_threads();
-
-// Take a best guess at the number of threads that can be run concurrently.
+// Attempt to determine number of available threads that can be run concurrently.
 // Will return at least 1.
-unsigned thread_concurrency();
+
+unsigned long thread_concurrency();
 
 // The list of logical processors for which the calling thread has affinity.
 // If calling from the main thread at application start up, before
@@ -35,6 +18,7 @@ unsigned thread_concurrency();
 //
 // Returns an empty vector if unable to determine the number of
 // available cores.
+
 std::vector<int> get_affinity();
 
 } // namespace arbenv
diff --git a/arborenv/include/arborenv/default_env.hpp b/arborenv/include/arborenv/default_env.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..843503b678ef1b6a0e87bdd9547f319c428a897a
--- /dev/null
+++ b/arborenv/include/arborenv/default_env.hpp
@@ -0,0 +1,49 @@
+#pragma once
+
+// Use heuristics, environment variables to determine a suitable context
+// proc_allocation.
+
+#include <arbor/context.hpp>
+
+#include <arborenv/arbenvexcept.hpp>
+#include <arborenv/concurrency.hpp>
+#include <arborenv/gpu_env.hpp>
+
+namespace arbenv {
+
+// Best-effort heuristics for thread utilization: use ARBENV_NUM_THREADS value
+// if set and non-zero, throwing arbev::invalid_env_value if it has an invalid
+// value, or else return the value determined by arbenv::thread_concurrency().
+
+unsigned long default_concurrency();
+
+// If Arbor is built without GPU support, return -1.
+//
+// If the ARBENV_GPU_ID environment variable is set, return -1 if it is less
+// than zero (indicating no GPU should be used), or its integer value if it is
+// a valid GPU device id.
+//
+// If ARBENV_GPU_ID is not set or empty, return 0 if 0 is a valid GPU device
+// id, and -1 otherwise.
+//
+// Throws arbenv::invalid_env_value if ARBENV_GPU_ID is not an int value, or
+// arbenv::no_such_gpu if it doesn't correspond to a valid GPU id.
+
+int default_gpu();
+
+// Construct default proc_allocation from `default_concurrency()` and
+// `default_gpu()`.
+
+inline arb::proc_allocation default_allocation() {
+    return arb::proc_allocation{static_cast<unsigned>(default_concurrency()), default_gpu()};
+}
+
+// Retrieve user-specified thread count from ARBENV_NUM_THREADS environment variable.
+//
+// * Throws arbenv::invalid_env_value if ARBENV_NUM_THREADS is set but contains a
+//   non-numeric, non-positive, or out of range value.
+// * Returns zero if ARBENV_NUM_THREADS is unset, or set and empty.
+
+unsigned long get_env_num_threads();
+
+} // namespace arbenv
diff --git a/arborenv/include/arborenv/gpu_env.hpp b/arborenv/include/arborenv/gpu_env.hpp
index 9db867af75d5da3d574123aba91d0f887b95e66f..2aefde7bae60862997405ce1595de5521ad14028 100644
--- a/arborenv/include/arborenv/gpu_env.hpp
+++ b/arborenv/include/arborenv/gpu_env.hpp
@@ -2,8 +2,6 @@
 
 namespace arbenv {
 
-int default_gpu();
-
 template <typename Comm>
 int find_private_gpu(Comm comm);
 
diff --git a/arborenv/private_gpu.cpp b/arborenv/private_gpu.cpp
index f029157112bfa0c06ec827aabb2c3a9a6bcea29f..23376454506a215669638bb6866d0e84bade1b54 100644
--- a/arborenv/private_gpu.cpp
+++ b/arborenv/private_gpu.cpp
@@ -5,6 +5,7 @@
 
 #include <mpi.h>
 
+#include <arborenv/arbenvexcept.hpp>
 #include <arborenv/gpu_env.hpp>
 #include "gpu_uuid.hpp"
 
@@ -45,10 +46,10 @@ int find_private_gpu(MPI_Comm comm) {
 
     if (test_global_error(local_error)) {
         if (local_error) {
-            throw std::runtime_error("unable to detect the unique id of visible GPUs: " + msg);
+            throw gpu_uuid_error("unable to detect the unique id of visible GPUs: " + msg);
         }
         else {
-            throw std::runtime_error("unable to detect the unique id of visible GPUs: error on another MPI rank");
+            throw gpu_uuid_error("unable to detect the unique id of visible GPUs: error on another MPI rank");
         }
     }
 
@@ -83,9 +84,9 @@ int find_private_gpu(MPI_Comm comm) {
     auto gpu = assign_gpu(global_uuids, gpu_partition, rank);
 
     if (test_global_error(gpu.error)) {
-        throw std::runtime_error(
-            "Unable to assign a unique GPU to MPI rank: the CUDA_VISIBLE_DEVICES"
-            " environment variable is likely incorrectly configured." );
+        throw gpu_uuid_error(
+            "unable to assign a unique GPU to MPI rank: the CUDA_VISIBLE_DEVICES"
+            " environment variable is likely incorrectly configured" );
     }
 
     return gpu.id;
diff --git a/arborenv/read_envvar.cpp b/arborenv/read_envvar.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..a39bf453e18ac955151cfd37365be7647d3bda7a
--- /dev/null
+++ b/arborenv/read_envvar.cpp
@@ -0,0 +1,49 @@
+#include <cerrno>
+#include <cstdlib>
+#include <optional>
+#include <stdexcept>
+#include <string>
+
+#include <arborenv/arbenvexcept.hpp>
+#include "read_envvar.hpp"
+
+using namespace std::literals;
+
+namespace arbenv {
+
+static std::optional<long long> read_env_integer_(const char* env_var, bool throw_on_err) {
+    const char* str = std::getenv(env_var);
+    if (!str || !*str) return std::nullopt;
+
+    char* end = 0;
+    errno = 0;
+    long long v = std::strtoll(str, &end, 10);
+    bool out_of_range = errno==ERANGE;
+    errno = 0;
+
+    if (out_of_range && throw_on_err) {
+        throw invalid_env_value(env_var, str);
+    }
+
+    while (*end && std::isspace(*end)) ++end;
+    if (*end) {
+        if (throw_on_err) {
+            throw invalid_env_value(env_var, str);
+        }
+        else {
+            return std::nullopt;
+        }
+    }
+
+    return v;
+}
+
+std::optional<long long> read_env_integer(const char* env_var) {
+    return read_env_integer_(env_var, false);
+}
+
+std::optional<long long> read_env_integer(const char* env_var, throw_on_invalid_t) {
+    return read_env_integer_(env_var, true);
+}
+
+} // namespace arbenv
diff --git a/arborenv/read_envvar.hpp b/arborenv/read_envvar.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..01f02e079c51348fed7de486c7072cbae530f401
--- /dev/null
+++ b/arborenv/read_envvar.hpp
@@ -0,0 +1,20 @@
+#include <optional>
+
+namespace arbenv {
+
+constexpr struct throw_on_invalid_t {} throw_on_invalid;
+
+// Return signed integer value represented by supplied environment variable.
+// If the environment variable is unset or empty or does not represent an integer, return std::nullopt.
+// If the value does not fit within the range of long long, return LLONG_MIN or LLONG_MAX based on its sign.
+
+std::optional<long long> read_env_integer(const char* env_var);
+
+// Return signed integer value represented by supplied environment variable.
+// If the environment variable is unset or empty, return std::nullopt.
+// If the environment variable does not represent an integer, throw arbenv::invalid_env_value.
+// If the value does not fit within the range of long long, throw arbenv::invalid_env_value.
+
+std::optional<long long> read_env_integer(const char* env_var, throw_on_invalid_t);
+
+} // namespace arbenv
diff --git a/doc/cpp/hardware.rst b/doc/cpp/hardware.rst
index bf0dbf6ac69603c86f20d2644836364d2f88a5ff..1389039b27bff057ac531e53cd74bee0eb7ef573 100644
--- a/doc/cpp/hardware.rst
+++ b/doc/cpp/hardware.rst
@@ -21,23 +21,24 @@ separation of concerns, so that users have full control over how hardware resour
 are selected, either using the functions and types in *libarborenv*, or writing their
 own code for managing MPI, GPUs, and thread counts.
 
+Functions for determining environment defaults based on system information and
+user-supplied values in environment values are in the header ``arborenv/default_env.hpp``.
+
 .. cpp:namespace:: arbenv
 
-.. cpp:function:: arb::util::optional<int> get_env_num_threads()
+.. cpp:function:: unsigned long get_env_num_threads()
 
-    Tests whether the number of threads to use has been set in an environment variable.
-    First checks ``ARB_NUM_THREADS``, and if that is not set checks ``OMP_NUM_THREADS``.
+    Retrieve user-specified number of threads to use from the environment variable
+    ARBENV_NUM_THREADS.
 
     Return value:
 
-    * **no value**: the :cpp:any:`optional` return value contains no value if the
-      no thread count was specified by an environment variable.
-    * **has value**: the number of threads set by the environment variable.
+    * Returns zero if ARBENV_NUM_THREADS is unset or empty.
+    * Returns positive unsigned long value on ARBENV_NUM_THREADS if set.
 
     Throws:
 
-    * throws :cpp:any:`std::runtime_error` if environment variable set with invalid
-      number of threads.
+    * Throws :cpp:any:`arbenv::invalid_env_value` if ARBENV_NUM_THREADS is set, non-empty, and not a valid representation of a positive unsigned long value.
 
     .. container:: example-code
 
@@ -49,48 +50,47 @@ own code for managing MPI, GPUs, and thread counts.
             std::cout << "requested " << nt.value() << "threads \n";
          }
          else {
-            std::cout << "no environment variable set\n";
+            std::cout << "environment variable empty or unset\n";
          }
 
-.. cpp:function:: int thread_concurrency()
-
-   Attempts to detect the number of available CPU cores. Returns 1 if unable to detect
-   the number of cores.
-
-    .. container:: example-code
+.. cpp:function:: arb::proc_allocation default_allocation()
 
-       .. code-block:: cpp
+   Return a :cpp:any:`proc_allocation` with thread count from :cpp:any:`default_concurrency()`
+   and gpu id from :cpp:any:`default_gpu()`.
 
-         #include <arborenv/concurrency.hpp>
+.. cpp:function:: unsigned long default_concurrency()
 
-         // Set num_threads to value from environment variable if set,
-         // otherwise set it to the available number of cores.
-         int num_threads = 0;
-         if (auto nt = arbenv::get_env_num_threads()) {
-            num_threads = nt.value();
-         }
-         else {
-            num_threads = arbenv::thread_concurrency();
-         }
+    Returns number of threads to use from :cpp:any:`get_env_num_threads()`, or else from
+    :cpp:any:`thread_concurrency()` if :cpp:any:`get_env_num_threads()` returns zero.
 
 .. cpp:function:: int default_gpu()
 
-   Returns the integer identifier of the first available GPU, if a GPU is available
+   Determine GPU id to use from the ARBENV_GPU_ID environment variable, or from the first available
+   GPU id of those detected.
 
    Return value:
 
-   * **non-negative value**: if a GPU is available, the index of the selected GPU is returned. The index will be in the range ``[0, num_gpus)`` where ``num_gpus`` is the number of GPUs detected using the ``cudaGetDeviceCount`` `CUDA API call <https://docs.nvidia.com/cuda/cuda-runtime-api/group__CUDART__DEVICE.html>`_.
-   * **-1**: if no GPU available, or if Arbor was built without GPU support.
+   * Return -1 if Arbor has no GPU support, or if the ARBENV_GPU_ID environment variable is set to a negative number, or if ARBENV_GPU_ID is empty or unset and no GPUs are detected.
+   * Return a non-negative GPU id equal to ARBENV_GPU_ID if it is set to a non-negative value that is a valid GPU id, or else to the first valid GPU id detected (typically zero).
 
-    .. container:: example-code
+   Throws:
 
-       .. code-block:: cpp
+   * Throws :cpp:any:`arbenv::invalid_env_value` if ARBENV_GPU_ID contains a non-integer value.
+   * Throws :cpp:any:`arbenv::no_such_gpu` if ARBENV_GPU_ID contains a non-negative integer that does not correspond to a detected GPU.
 
-         #include <arborenv/gpu_env.hpp>
+The header ``arborenv/concurrency.hpp`` supplies lower-level functions for querying the threading environment.
 
-         if (arbenv::default_gpu()>-1) {}
-            std::cout << "a GPU is available\n";
-         }
+.. cpp:function:: unsigned long thread_concurrency()
+
+   Attempts to detect the number of available CPU cores. Returns 1 if unable to detect
+   the number of cores.
+
+.. cpp:function:: std::vector<int> get_affinity()
+
+   Returns the list of logical processor ids where the calling thread has affinity,
+   or an empty vector if unable to determine.
+
+The header ``arborenv/gpu_env.hpp`` supplies lower-level functions for queruing the GPU environment.
 
 .. cpp:function:: int find_private_gpu(MPI_Comm comm)
 
@@ -119,10 +119,13 @@ own code for managing MPI, GPUs, and thread counts.
 
    Throws:
 
-     * :cpp:any:`std::runtime_error`: if there was an error in the CUDA runtime
+     * :cpp:any:`arbenv::gpu_uuid_error`: if there was an error in the CUDA runtime
        on the local or remote MPI ranks, i.e. if one rank throws, all ranks
        will throw.
 
+The header ``arborenv/with_mpi.hpp`` provides an RAII interface for initializing MPI
+and handling exceptions on MPI exit.
+
 .. cpp:class:: with_mpi
 
    The :cpp:class:`with_mpi` type is a simple RAII scoped guard for MPI initialization
@@ -186,6 +189,10 @@ own code for managing MPI, GPUs, and thread counts.
                 return 0;
             }
 
+Functions and methods in the ``arborenv`` library may throw exceptions specific to the library.
+These are declared in the ``arborenv/arbenvexcept.hpp`` header, and all derive from the
+class ``arborenv::arborenv_exception``, itself derived from ``std::runtime_error``.
+
 libarbor
 -------------------
 
diff --git a/example/bench/bench.cpp b/example/bench/bench.cpp
index 9006af75562143446b8005f68985248f93cc591c..0d6fb97679be57675881846ba1db1f6d143ba621 100644
--- a/example/bench/bench.cpp
+++ b/example/bench/bench.cpp
@@ -18,8 +18,7 @@
 #include <arbor/simulation.hpp>
 #include <arbor/version.hpp>
 
-
-#include <arborenv/concurrency.hpp>
+#include <arborenv/default_env.hpp>
 #include <arborenv/gpu_env.hpp>
 
 #include <sup/ioutil.hpp>
@@ -135,22 +134,14 @@ int main(int argc, char** argv) {
     bool is_root = true;
 
     try {
-        arb::proc_allocation resources;
-        if (auto nt = arbenv::get_env_num_threads()) {
-            resources.num_threads = nt;
-        }
-        else {
-            resources.num_threads = arbenv::thread_concurrency();
-        }
-
 #ifdef ARB_MPI_ENABLED
         arbenv::with_mpi guard(argc, argv, false);
-        resources.gpu_id = arbenv::find_private_gpu(MPI_COMM_WORLD);
-        auto context = arb::make_context(resources, MPI_COMM_WORLD);
+        auto num_threads = arbenv::default_concurrency();
+        auto gpu_id = arbenv::find_private_gpu(MPI_COMM_WORLD);
+        auto context = arb::make_context({num_threads, gpu_id}, MPI_COMM_WORLD);
         is_root = arb::rank(context) == 0;
 #else
-        resources.gpu_id = arbenv::default_gpu();
-        auto context = arb::make_context(resources);
+        auto context = arb::make_context(arbenv::default_allocation());
 #endif
 #ifdef ARB_PROFILE_ENABLED
         profile::profiler_initialize(context);
diff --git a/example/brunel/brunel.cpp b/example/brunel/brunel.cpp
index 40029aa0e16a4126f9215362539fda817d353078..4343e5933e2f72352eacd1bf06cae4afbd1e06a0 100644
--- a/example/brunel/brunel.cpp
+++ b/example/brunel/brunel.cpp
@@ -22,7 +22,7 @@
 #include <arbor/simulation.hpp>
 #include <arbor/version.hpp>
 
-#include <arborenv/concurrency.hpp>
+#include <arborenv/default_env.hpp>
 #include <arborenv/gpu_env.hpp>
 
 #include <sup/ioutil.hpp>
@@ -184,22 +184,14 @@ int main(int argc, char** argv) {
     bool root = true;
 
     try {
-        arb::proc_allocation resources;
-        if (auto nt = arbenv::get_env_num_threads()) {
-            resources.num_threads = nt;
-        }
-        else {
-            resources.num_threads = arbenv::thread_concurrency();
-        }
-
 #ifdef ARB_MPI_ENABLED
         arbenv::with_mpi guard(argc, argv, false);
-        resources.gpu_id = arbenv::find_private_gpu(MPI_COMM_WORLD);
-        auto context = arb::make_context(resources, MPI_COMM_WORLD);
+        unsigned num_threads = arbenv::default_concurrency();
+        int gpu_id = arbenv::find_private_gpu(MPI_COMM_WORLD);
+        auto context = arb::make_context(arb::proc_allocation{num_threads, gpu_id}, MPI_COMM_WORLD);
         root = arb::rank(context)==0;
 #else
-        resources.gpu_id = arbenv::default_gpu();
-        auto context = arb::make_context(resources);
+        auto context = arb::make_context(arbenv::default_allocation());
 #endif
 
         std::cout << sup::mask_stream(root);
diff --git a/example/gap_junctions/gap_junctions.cpp b/example/gap_junctions/gap_junctions.cpp
index c11802dcc3c591608cdbc68c9c42b5e62bbe0a91..d1ea8f6fd87be141a20baa331a513bfe47c031cf 100644
--- a/example/gap_junctions/gap_junctions.cpp
+++ b/example/gap_junctions/gap_junctions.cpp
@@ -25,7 +25,7 @@
 #include <arbor/recipe.hpp>
 #include <arbor/version.hpp>
 
-#include <arborenv/concurrency.hpp>
+#include <arborenv/default_env.hpp>
 #include <arborenv/gpu_env.hpp>
 
 #include <sup/ioutil.hpp>
@@ -135,26 +135,18 @@ int main(int argc, char** argv) {
     try {
         bool root = true;
 
-        arb::proc_allocation resources;
-        if (auto nt = arbenv::get_env_num_threads()) {
-            resources.num_threads = nt;
-        }
-        else {
-            resources.num_threads = arbenv::thread_concurrency();
-        }
-
 #ifdef ARB_MPI_ENABLED
         arbenv::with_mpi guard(argc, argv, false);
-        resources.gpu_id = arbenv::find_private_gpu(MPI_COMM_WORLD);
-        auto context = arb::make_context(resources, MPI_COMM_WORLD);
+        unsigned nt = arbenv::default_concurrency();
+        int gpu_id = arbenv::find_private_gpu(MPI_COMM_WORLD);
+        auto context = arb::make_context(arb::proc_allocation{nt, gpu_id}, MPI_COMM_WORLD);
         {
             int rank;
             MPI_Comm_rank(MPI_COMM_WORLD, &rank);
             root = rank==0;
         }
 #else
-        resources.gpu_id = arbenv::default_gpu();
-        auto context = arb::make_context(resources);
+        auto context = arb::make_context(arbenv::default_allocation());
 #endif
 
 #ifdef ARB_PROFILE_ENABLED
diff --git a/example/ring/ring.cpp b/example/ring/ring.cpp
index 5e9c187e9e87a16225296aaed4069af573589c51..8e66f2ef2ea9fbb1547d17ab8965768ef42bf5a4 100644
--- a/example/ring/ring.cpp
+++ b/example/ring/ring.cpp
@@ -26,7 +26,7 @@
 #include <arbor/recipe.hpp>
 #include <arbor/version.hpp>
 
-#include <arborenv/concurrency.hpp>
+#include <arborenv/default_env.hpp>
 #include <arborenv/gpu_env.hpp>
 
 #include <sup/ioutil.hpp>
@@ -128,12 +128,7 @@ int main(int argc, char** argv) {
         bool root = true;
 
         arb::proc_allocation resources;
-        if (auto nt = arbenv::get_env_num_threads()) {
-            resources.num_threads = nt;
-        }
-        else {
-            resources.num_threads = arbenv::thread_concurrency();
-        }
+        resources.num_threads = arbenv::default_concurrency();
 
 #ifdef ARB_MPI_ENABLED
         arbenv::with_mpi guard(argc, argv, false);
diff --git a/test/unit/test_domain_decomposition.cpp b/test/unit/test_domain_decomposition.cpp
index b2f0db19d7041bc2799da66df447140a17dbdabf..2d71b23990634da22a07525ec4031d67a7d232f1 100644
--- a/test/unit/test_domain_decomposition.cpp
+++ b/test/unit/test_domain_decomposition.cpp
@@ -6,7 +6,7 @@
 #include <arbor/domain_decomposition.hpp>
 #include <arbor/load_balance.hpp>
 
-#include <arborenv/gpu_env.hpp>
+#include <arborenv/default_env.hpp>
 
 #include "util/span.hpp"
 
diff --git a/test/unit/test_fvm_layout.cpp b/test/unit/test_fvm_layout.cpp
index 0d227ee20246978a7e28e2edddc4f7a8d61cb9d1..ac1294fb2c9b1d33f149693ecdf23b708f762efd 100644
--- a/test/unit/test_fvm_layout.cpp
+++ b/test/unit/test_fvm_layout.cpp
@@ -12,7 +12,7 @@
 
 #include <arborio/label_parse.hpp>
 
-#include <arborenv/concurrency.hpp>
+#include <arborenv/default_env.hpp>
 
 #include "backends/multicore/fvm.hpp"
 #include "fvm_lowered_cell.hpp"
@@ -609,13 +609,7 @@ TEST(fvm_layout, synapse_targets) {
 }
 
 TEST(fvm_lowered, gj_example_0) {
-    arb::proc_allocation resources;
-    if (auto nt = arbenv::get_env_num_threads()) {
-        resources.num_threads = nt;
-    } else {
-        resources.num_threads = arbenv::thread_concurrency();
-    }
-    arb::execution_context context(resources);
+    auto context = make_context({arbenv::default_concurrency(), -1});
 
     class gap_recipe: public recipe {
     public:
@@ -661,7 +655,7 @@ TEST(fvm_lowered, gj_example_0) {
 
     std::vector<cell_gid_type> gids = {0, 1};
 
-    auto D = fvm_cv_discretize(cells, gprop.default_parameters, context);
+    auto D = fvm_cv_discretize(cells, gprop.default_parameters, *context);
     auto gj_cvs = fvm_build_gap_junction_cv_map(cells, gids, D);
 
     auto cv_0 = D.geometry.location_cv(0, loc_0, cv_prefer::cv_nonempty);
@@ -673,7 +667,7 @@ TEST(fvm_lowered, gj_example_0) {
     EXPECT_EQ(cv_1, gj_cvs.at(cell_member_type{1, 0}));
 
     // Check the resolved GJ connections
-    fvm_cell fvcell(context);
+    fvm_cell fvcell(*context);
     gap_recipe rec(cells, gprop);
 
     auto fvm_info = fvcell.initialize(gids, rec);
@@ -689,7 +683,7 @@ TEST(fvm_lowered, gj_example_0) {
     EXPECT_EQ(gj1, gj_conns.at(1).front());
 
     // Check the GJ mechanism data
-    auto M = fvm_build_mechanism_data(gprop, cells, gids, gj_conns, D, context);
+    auto M = fvm_build_mechanism_data(gprop, cells, gids, gj_conns, D, *context);
 
     EXPECT_EQ(1u, M.mechanisms.size());
     ASSERT_EQ(1u, M.mechanisms.count("gj"));
@@ -718,13 +712,7 @@ TEST(fvm_lowered, gj_example_0) {
 }
 
 TEST(fvm_lowered, gj_example_1) {
-    arb::proc_allocation resources;
-    if (auto nt = arbenv::get_env_num_threads()) {
-        resources.num_threads = nt;
-    } else {
-        resources.num_threads = arbenv::thread_concurrency();
-    }
-    arb::execution_context context(resources);
+    auto context = make_context({arbenv::default_concurrency(), -1});
 
     class gap_recipe: public recipe {
     public:
@@ -813,7 +801,7 @@ TEST(fvm_lowered, gj_example_1) {
     std::vector<cable_cell> cells{c0, c1, c2};
     std::vector<cell_gid_type> gids = {0, 1, 2};
 
-    auto D = fvm_cv_discretize(cells, neuron_parameter_defaults, context);
+    auto D = fvm_cv_discretize(cells, neuron_parameter_defaults, *context);
     unsigned c0_gj_cv[2], c1_gj_cv[4], c2_gj_cv[3];
     for (int i = 0; i<2; ++i) c0_gj_cv[i] = D.geometry.location_cv(0, c0_gj[i], cv_prefer::cv_nonempty);
     for (int i = 0; i<4; ++i) c1_gj_cv[i] = D.geometry.location_cv(1, c1_gj[i], cv_prefer::cv_nonempty);
@@ -833,7 +821,7 @@ TEST(fvm_lowered, gj_example_1) {
     EXPECT_EQ(c2_gj_cv[2], gj_cvs.at(cell_member_type{2, 2}));
 
     // Check the resolved GJ connections
-    fvm_cell fvcell(context);
+    fvm_cell fvcell(*context);
     gap_recipe rec(cells, gprop);
 
     auto fvm_info = fvcell.initialize(gids, rec);
@@ -866,7 +854,7 @@ TEST(fvm_lowered, gj_example_1) {
     EXPECT_EQ(expected.at(2), gj_conns.at(2));
 
     // Check the GJ mechanism data
-    auto M = fvm_build_mechanism_data(gprop, cells, gids, gj_conns, D, context);
+    auto M = fvm_build_mechanism_data(gprop, cells, gids, gj_conns, D, *context);
 
     EXPECT_EQ(1u, M.mechanisms.size());
     ASSERT_EQ(1u, M.mechanisms.count("gj"));
@@ -903,13 +891,7 @@ TEST(fvm_lowered, gj_example_1) {
 }
 
 TEST(fvm_layout, gj_example_2) {
-    arb::proc_allocation resources;
-    if (auto nt = arbenv::get_env_num_threads()) {
-        resources.num_threads = nt;
-    } else {
-        resources.num_threads = arbenv::thread_concurrency();
-    }
-    arb::execution_context context(resources);
+    auto context = make_context({arbenv::default_concurrency(), -1});
 
     class gap_recipe: public recipe {
     public:
@@ -1033,7 +1015,7 @@ TEST(fvm_layout, gj_example_2) {
     EXPECT_EQ(cvs_5[1], gj_cvs.at(cell_member_type{5, 1}));
 
     // Check the resolved GJ connections
-    fvm_cell fvcell(context);
+    fvm_cell fvcell(*context);
     gap_recipe rec(cells, gprop);
 
     auto fvm_info = fvcell.initialize(gids, rec);
@@ -1080,7 +1062,7 @@ TEST(fvm_layout, gj_example_2) {
     EXPECT_EQ(expected.at(5), gj_conns.at(5));
 
     // Check the GJ mechanism data
-    auto M = fvm_build_mechanism_data(gprop, cells, gids, gj_conns, D, context);
+    auto M = fvm_build_mechanism_data(gprop, cells, gids, gj_conns, D, *context);
 
     EXPECT_EQ(4u, M.mechanisms.size());
     ASSERT_EQ(1u, M.mechanisms.count("gj0"));
@@ -1150,14 +1132,7 @@ TEST(fvm_layout, gj_example_2) {
 }
 
 TEST(fvm_lowered, cell_group_gj) {
-    arb::proc_allocation resources;
-    if (auto nt = arbenv::get_env_num_threads()) {
-        resources.num_threads = nt;
-    }
-    else {
-        resources.num_threads = arbenv::thread_concurrency();
-    }
-    arb::execution_context context(resources);
+    auto context = make_context({arbenv::default_concurrency(), -1});
 
     class gap_recipe: public recipe {
     public:
@@ -1217,8 +1192,8 @@ TEST(fvm_lowered, cell_group_gj) {
 
     gap_recipe rec(cell_group0, cell_group1);
 
-    fvm_cell fvcell0(context);
-    fvm_cell fvcell1(context);
+    fvm_cell fvcell0(*context);
+    fvm_cell fvcell1(*context);
 
     auto fvm_info_0 = fvcell0.initialize(gids_cg0, rec);
     auto fvm_info_1 = fvcell1.initialize(gids_cg1, rec);
@@ -1226,8 +1201,8 @@ TEST(fvm_lowered, cell_group_gj) {
     auto num_dom0 = fvcell0.fvm_intdom(rec, gids_cg0, fvm_info_0.cell_to_intdom);
     auto num_dom1 = fvcell1.fvm_intdom(rec, gids_cg1, fvm_info_1.cell_to_intdom);
 
-    fvm_cv_discretization D0 = fvm_cv_discretize(cell_group0, neuron_parameter_defaults, context);
-    fvm_cv_discretization D1 = fvm_cv_discretize(cell_group1, neuron_parameter_defaults, context);
+    fvm_cv_discretization D0 = fvm_cv_discretize(cell_group0, neuron_parameter_defaults, *context);
+    fvm_cv_discretization D1 = fvm_cv_discretize(cell_group1, neuron_parameter_defaults, *context);
 
     auto gj_cvs_0 = fvm_build_gap_junction_cv_map(cell_group0, gids_cg0, D0);
     auto gj_cvs_1 = fvm_build_gap_junction_cv_map(cell_group1, gids_cg1, D1);
diff --git a/test/unit/test_fvm_lowered.cpp b/test/unit/test_fvm_lowered.cpp
index cee09538b14da19158480b20a10d7d3c49c6462c..434a5db473d037f17d8bd6d50765c9fd476100c1 100644
--- a/test/unit/test_fvm_lowered.cpp
+++ b/test/unit/test_fvm_lowered.cpp
@@ -20,10 +20,9 @@
 #include <arbor/mechanism.hpp>
 #include <arbor/util/any_ptr.hpp>
 
-#include <arborenv/concurrency.hpp>
+#include <arborenv/default_env.hpp>
 
 #include "backends/multicore/fvm.hpp"
-#include "execution_context.hpp"
 #include "fvm_lowered_cell.hpp"
 #include "fvm_lowered_cell_impl.hpp"
 #include "util/meta.hpp"
@@ -190,14 +189,7 @@ private:
 
 TEST(fvm_lowered, matrix_init)
 {
-    arb::proc_allocation resources;
-    if (auto nt = arbenv::get_env_num_threads()) {
-        resources.num_threads = nt;
-    }
-    else {
-        resources.num_threads = arbenv::thread_concurrency();
-    }
-    arb::execution_context context(resources);
+    auto context = make_context({arbenv::default_concurrency(), -1});
 
     auto isnan = [](auto v) { return std::isnan(v); };
     auto ispos = [](auto v) { return v>0; };
@@ -207,7 +199,7 @@ TEST(fvm_lowered, matrix_init)
     builder.add_branch(0, 200, 1.0/2, 1.0/2, 10, "dend"); // 10 compartments
     cable_cell cell = builder.make_cell();
 
-    fvm_cell fvcell(context);
+    fvm_cell fvcell(*context);
     fvcell.initialize({0}, cable1d_recipe(cell));
 
     auto& J = fvcell.*private_matrix_ptr;
@@ -230,16 +222,7 @@ TEST(fvm_lowered, matrix_init)
 }
 
 TEST(fvm_lowered, target_handles) {
-    using namespace arb;
-
-    arb::proc_allocation resources;
-    if (auto nt = arbenv::get_env_num_threads()) {
-        resources.num_threads = nt;
-    }
-    else {
-        resources.num_threads = arbenv::thread_concurrency();
-    }
-    arb::execution_context context(resources);
+    auto context = make_context(proc_allocation(arbenv::default_concurrency(), -1));
 
     cable_cell_description descriptions[] = {
         make_cell_ball_and_stick(),
@@ -287,11 +270,11 @@ TEST(fvm_lowered, target_handles) {
         EXPECT_EQ(1u, targets[3].intdom_index);
     };
 
-    fvm_cell fvcell0(context);
+    fvm_cell fvcell0(*context);
     auto fvm_info0 = fvcell0.initialize({0, 1}, cable1d_recipe(cells, true));
     test_target_handles(fvcell0, fvm_info0.target_handles);
 
-    fvm_cell fvcell1(context);
+    fvm_cell fvcell1(*context);
     auto fvm_info1 = fvcell1.initialize({0, 1}, cable1d_recipe(cells, false));
     test_target_handles(fvcell1, fvm_info1.target_handles);
 
@@ -308,14 +291,7 @@ TEST(fvm_lowered, stimulus) {
     // amplitude | 0.3  |  0.1
     // CV        |   5  |    0
 
-    arb::proc_allocation resources;
-    if (auto nt = arbenv::get_env_num_threads()) {
-        resources.num_threads = nt;
-    }
-    else {
-        resources.num_threads = arbenv::thread_concurrency();
-    }
-    arb::execution_context context(resources);
+    auto context = make_context({arbenv::default_concurrency(), -1});
 
     auto desc = make_cell_ball_and_stick(false);
 
@@ -335,10 +311,10 @@ TEST(fvm_lowered, stimulus) {
     cable_cell_global_properties gprop;
     gprop.default_parameters = neuron_parameter_defaults;
 
-    fvm_cv_discretization D = fvm_cv_discretize(cells, gprop.default_parameters, context);
+    fvm_cv_discretization D = fvm_cv_discretize(cells, gprop.default_parameters, *context);
     const auto& A = D.cv_area;
 
-    fvm_cell fvcell(context);
+    fvm_cell fvcell(*context);
     fvcell.initialize({0}, cable1d_recipe(cells));
 
     auto& state = *(fvcell.*private_state_ptr).get();
@@ -380,7 +356,7 @@ TEST(fvm_lowered, stimulus) {
 TEST(fvm_lowered, ac_stimulus) {
     // Simple cell (one CV) with oscillating stimulus.
 
-    arb::execution_context context; // Just use default context for this one!
+    auto context = make_context(); // Just use default context for this one!
 
     decor dec;
     segment_tree tree;
@@ -398,10 +374,10 @@ TEST(fvm_lowered, ac_stimulus) {
     cable_cell_global_properties gprop;
     gprop.default_parameters = neuron_parameter_defaults;
 
-    fvm_cv_discretization D = fvm_cv_discretize(cells, gprop.default_parameters, context);
+    fvm_cv_discretization D = fvm_cv_discretize(cells, gprop.default_parameters, *context);
     const auto& A = D.cv_area;
 
-    fvm_cell fvcell(context);
+    fvm_cell fvcell(*context);
     fvcell.initialize({0}, cable1d_recipe(cells));
 
     auto& state = *(fvcell.*private_state_ptr).get();
@@ -440,13 +416,7 @@ TEST(fvm_lowered, derived_mechs) {
     //
     // 3. Cell with both test_kin1 and custom_kin1.
 
-    arb::proc_allocation resources;
-    if (auto nt = arbenv::get_env_num_threads()) {
-        resources.num_threads = nt;
-    }
-    else {
-        resources.num_threads = arbenv::thread_concurrency();
-    }
+    auto context = make_context({arbenv::default_concurrency(), -1});
 
     std::vector<cable_cell> cells;
     cells.reserve(3);
@@ -482,8 +452,7 @@ TEST(fvm_lowered, derived_mechs) {
     {
         // Test initialization and global parameter values.
 
-        arb::execution_context context(resources);
-        fvm_cell fvcell(context);
+        fvm_cell fvcell(*context);
         fvcell.initialize({0, 1, 2}, rec);
 
         // Both mechanisms will have the same internal name, "test_kin1".
@@ -517,9 +486,8 @@ TEST(fvm_lowered, derived_mechs) {
 
         float times[] = {10.f, 20.f};
 
-        auto ctx = make_context(resources);
-        auto decomp = partition_load_balance(rec, ctx);
-        simulation sim(rec, decomp, ctx);
+        auto decomp = partition_load_balance(rec, context);
+        simulation sim(rec, decomp, context);
         sim.add_sampler(all_probes, explicit_schedule(times), sampler);
         sim.run(30.0, 1.f/1024);
 
@@ -536,13 +504,7 @@ TEST(fvm_lowered, derived_mechs) {
 }
 
 TEST(fvm_lowered, null_region) {
-    arb::proc_allocation resources;
-    if (auto nt = arbenv::get_env_num_threads()) {
-        resources.num_threads = nt;
-    }
-    else {
-        resources.num_threads = arbenv::thread_concurrency();
-    }
+    auto context = make_context({arbenv::default_concurrency(), -1});
 
     soma_cell_builder builder(6);
     builder.add_branch(0, 100, 0.5, 0.5, 4, "dend");
@@ -555,9 +517,8 @@ TEST(fvm_lowered, null_region) {
     rec.catalogue() = make_unit_test_catalogue();
     rec.catalogue().derive("custom_kin1", "test_kin1", {{"tau", 20.0}});
 
-    auto ctx = make_context(resources);
-    auto decomp = partition_load_balance(rec, ctx);
-    simulation sim(rec, decomp, ctx);
+    auto decomp = partition_load_balance(rec, context);
+    simulation sim(rec, decomp, context);
     EXPECT_NO_THROW(sim.run(30.0, 1.f/1024));
 }
 
@@ -565,13 +526,7 @@ TEST(fvm_lowered, null_region) {
 // Test that ion charge is propagated into mechanism variable.
 
 TEST(fvm_lowered, read_valence) {
-    arb::proc_allocation resources;
-    if (auto nt = arbenv::get_env_num_threads()) {
-        resources.num_threads = nt;
-    }
-    else {
-        resources.num_threads = arbenv::thread_concurrency();
-    }
+    auto context = make_context({arbenv::default_concurrency(), -1});
 
     {
         std::vector<cable_cell> cells(1);
@@ -582,8 +537,7 @@ TEST(fvm_lowered, read_valence) {
         cable1d_recipe rec(cable_cell{cell});
         rec.catalogue() = make_unit_test_catalogue();
 
-        arb::execution_context context(resources);
-        fvm_cell fvcell(context);
+        fvm_cell fvcell(*context);
         fvcell.initialize({0}, rec);
 
         // test_ca_read_valence initialization should write ca ion valence
@@ -607,8 +561,7 @@ TEST(fvm_lowered, read_valence) {
         rec.catalogue().derive("cr_read_valence", "na_read_valence", {}, {{"na", "mn"}});
         rec.add_ion("mn", 7, 0, 0, 0);
 
-        arb::execution_context context(resources);
-        fvm_cell fvcell(context);
+        fvm_cell fvcell(*context);
         fvcell.initialize({0}, rec);
 
         auto cr_mech_ptr = find_mechanism(fvcell, 0);
@@ -682,14 +635,7 @@ TEST(fvm_lowered, ionic_concentrations) {
 }
 
 TEST(fvm_lowered, ionic_currents) {
-    arb::proc_allocation resources;
-    if (auto nt = arbenv::get_env_num_threads()) {
-        resources.num_threads = nt;
-    }
-    else {
-        resources.num_threads = arbenv::thread_concurrency();
-    }
-    arb::execution_context context(resources);
+    auto context = make_context({arbenv::default_concurrency(), -1});
 
     soma_cell_builder b(6);
 
@@ -717,7 +663,7 @@ TEST(fvm_lowered, ionic_currents) {
     cable1d_recipe rec({cable_cell{c}});
     rec.catalogue() = make_unit_test_catalogue();
 
-    fvm_cell fvcell(context);
+    fvm_cell fvcell(*context);
     fvcell.initialize({0}, rec);
 
     auto& state = *(fvcell.*private_state_ptr).get();
@@ -737,14 +683,7 @@ TEST(fvm_lowered, ionic_currents) {
 // Test correct scaling of an ionic current updated via a point mechanism
 
 TEST(fvm_lowered, point_ionic_current) {
-    arb::proc_allocation resources;
-    if (auto nt = arbenv::get_env_num_threads()) {
-        resources.num_threads = nt;
-    }
-    else {
-        resources.num_threads = arbenv::thread_concurrency();
-    }
-    arb::execution_context context(resources);
+    auto context = make_context({arbenv::default_concurrency(), -1});
 
     double r = 6.0; // [µm]
     soma_cell_builder b(r);
@@ -758,7 +697,7 @@ TEST(fvm_lowered, point_ionic_current) {
     cable1d_recipe rec({cable_cell{c}});
     rec.catalogue() = make_unit_test_catalogue();
 
-    fvm_cell fvcell(context);
+    fvm_cell fvcell(*context);
     fvcell.initialize({0}, rec);
 
     // Only one target, corresponding to our point process on soma.
@@ -806,14 +745,7 @@ TEST(fvm_lowered, weighted_write_ion) {
     // the same as a 100 µm dendrite, which makes it easier to describe the
     // expected weights.
 
-    arb::proc_allocation resources;
-    if (auto nt = arbenv::get_env_num_threads()) {
-        resources.num_threads = nt;
-    }
-    else {
-        resources.num_threads = arbenv::thread_concurrency();
-    }
-    arb::execution_context context(resources);
+    auto context = make_context({arbenv::default_concurrency(), -1});
 
     soma_cell_builder b(5);
     b.add_branch(0, 100, 0.5, 0.5, 1, "dend");
@@ -835,7 +767,7 @@ TEST(fvm_lowered, weighted_write_ion) {
     rec.catalogue() = make_unit_test_catalogue();
     rec.add_ion("ca", 2, con_int, con_ext, 0.0);
 
-    fvm_cell fvcell(context);
+    fvm_cell fvcell(*context);
     fvcell.initialize({0}, rec);
 
     auto& state = *(fvcell.*private_state_ptr).get();
@@ -875,8 +807,8 @@ TEST(fvm_lowered, weighted_write_ion) {
 
 TEST(fvm_lowered, integration_domains) {
     {
-        execution_context context;
-        fvm_cell fvcell(context);
+        auto context = make_context();
+        fvm_cell fvcell(*context);
 
         std::vector<cell_gid_type> gids = {11u, 5u, 2u, 3u, 0u, 8u, 7u};
         std::vector<fvm_index_type> cell_to_intdom;
@@ -888,8 +820,8 @@ TEST(fvm_lowered, integration_domains) {
         EXPECT_EQ(expected_doms, cell_to_intdom);
     }
     {
-        execution_context context;
-        fvm_cell fvcell(context);
+        auto context = make_context();
+        fvm_cell fvcell(*context);
 
         std::vector<cell_gid_type> gids = {11u, 5u, 2u, 3u, 0u, 8u, 7u};
         std::vector<fvm_index_type> cell_to_intdom;
@@ -901,8 +833,8 @@ TEST(fvm_lowered, integration_domains) {
         EXPECT_EQ(expected_doms, cell_to_intdom);
     }
     {
-        execution_context context;
-        fvm_cell fvcell(context);
+        auto context = make_context();
+        fvm_cell fvcell(*context);
 
         std::vector<cell_gid_type> gids = {5u, 2u, 3u, 0u};
         std::vector<fvm_index_type> cell_to_intdom;
@@ -916,13 +848,7 @@ TEST(fvm_lowered, integration_domains) {
 }
 
 TEST(fvm_lowered, post_events_shared_state) {
-    arb::proc_allocation resources;
-    if (auto nt = arbenv::get_env_num_threads()) {
-        resources.num_threads = nt;
-    } else {
-        resources.num_threads = arbenv::thread_concurrency();
-    }
-    arb::execution_context context(resources);
+    auto context = make_context({arbenv::default_concurrency(), -1});
 
     class detector_recipe: public arb::recipe {
     public:
@@ -989,7 +915,7 @@ TEST(fvm_lowered, post_events_shared_state) {
     for (const auto& detectors_per_cell: detectors_per_cell_vec) {
         detector_recipe rec(cv_per_cell, detectors_per_cell, "post_events_syn");
 
-        fvm_cell fvcell(context);
+        fvm_cell fvcell(*context);
         fvcell.initialize(gids, rec);
 
         auto& S = fvcell.*private_state_ptr;
@@ -1010,7 +936,7 @@ TEST(fvm_lowered, post_events_shared_state) {
     for (const auto& detectors_per_cell: detectors_per_cell_vec) {
         detector_recipe rec(cv_per_cell, detectors_per_cell, "expsyn");
 
-        fvm_cell fvcell(context);
+        fvm_cell fvcell(*context);
         fvcell.initialize(gids, rec);
 
         auto& S = fvcell.*private_state_ptr;
@@ -1022,13 +948,7 @@ TEST(fvm_lowered, post_events_shared_state) {
 }
 
 TEST(fvm_lowered, label_data) {
-    arb::proc_allocation resources;
-    if (auto nt = arbenv::get_env_num_threads()) {
-        resources.num_threads = nt;
-    } else {
-        resources.num_threads = arbenv::thread_concurrency();
-    }
-    arb::execution_context context(resources);
+    auto context = make_context({arbenv::default_concurrency(), -1});
 
     class decorated_recipe: public arb::recipe {
     public:
@@ -1101,7 +1021,7 @@ TEST(fvm_lowered, label_data) {
     std::vector<target_handle> targets;
     probe_association_map probe_map;
 
-    fvm_cell fvcell(context);
+    fvm_cell fvcell(*context);
     auto fvm_info = fvcell.initialize(gids, rec);
 
     for (auto gid: gids) {
diff --git a/test/unit/test_mc_cell_group_gpu.cpp b/test/unit/test_mc_cell_group_gpu.cpp
index d2bbcd187b60db73d6b9050e5593cfcc07b5331c..25ce0511ee077c8d3faaed9dddd948abc70462ea 100644
--- a/test/unit/test_mc_cell_group_gpu.cpp
+++ b/test/unit/test_mc_cell_group_gpu.cpp
@@ -2,7 +2,7 @@
 
 #include <arbor/common_types.hpp>
 #include <arborio/label_parse.hpp>
-#include <arborenv/gpu_env.hpp>
+#include <arborenv/default_env.hpp>
 
 #include "epoch.hpp"
 #include "execution_context.hpp"
@@ -16,13 +16,6 @@ using namespace arb;
 using namespace arborio::literals;
 
 namespace {
-    fvm_lowered_cell_ptr lowered_cell() {
-        arb::proc_allocation resources;
-        resources.gpu_id = arbenv::default_gpu();
-        execution_context context(resources);
-        return make_fvm_lowered_cell(backend_kind::gpu, context);
-    }
-
     cable_cell_description make_cell() {
         soma_cell_builder builder(12.6157/2.0);
         builder.add_branch(0, 200, 0.5, 0.5, 101, "dend");
@@ -37,6 +30,8 @@ namespace {
 
 TEST(mc_cell_group, gpu_test)
 {
+    auto context = make_context({1, arbenv::default_gpu()});
+
     cable_cell cell = make_cell();
     auto rec = cable1d_recipe({cell});
     rec.nernst_ion("na");
@@ -44,7 +39,7 @@ TEST(mc_cell_group, gpu_test)
     rec.nernst_ion("k");
 
     cell_label_range srcs, tgts;
-    mc_cell_group group{{0}, rec, srcs, tgts, lowered_cell()};
+    mc_cell_group group{{0}, rec, srcs, tgts, make_fvm_lowered_cell(backend_kind::gpu, *context)};
     group.advance(epoch(0, 0., 50.), 0.01, {});
 
     // The model is expected to generate 4 spikes as a result of the
diff --git a/test/unit/test_probe.cpp b/test/unit/test_probe.cpp
index dca9870b37d9efcab01056e273ef6b7f1cbf22fa..02b570b1219ab12a987cb2bbcc73f3b83c96bf44 100644
--- a/test/unit/test_probe.cpp
+++ b/test/unit/test_probe.cpp
@@ -18,9 +18,10 @@
 #include <arbor/util/any_ptr.hpp>
 #include <arbor/util/pp_util.hpp>
 #include <arbor/version.hpp>
-#include <arborenv/gpu_env.hpp>
 #include <arbor/mechanism.hpp>
 
+#include <arborenv/default_env.hpp>
+
 #include "backends/event.hpp"
 #include "backends/multicore/fvm.hpp"
 #ifdef ARB_GPU_ENABLED
diff --git a/test/unit/test_recipe.cpp b/test/unit/test_recipe.cpp
index 37d1a7c729a5f1a5bdb33dbf41da9fc4ac2d7197..f630f851f097fc16faa12ede8015176f012e1070 100644
--- a/test/unit/test_recipe.cpp
+++ b/test/unit/test_recipe.cpp
@@ -10,7 +10,7 @@
 #include <arbor/recipe.hpp>
 #include <arbor/simulation.hpp>
 
-#include <arborenv/concurrency.hpp>
+#include <arborenv/default_env.hpp>
 
 #include "util/span.hpp"
 
@@ -94,14 +94,7 @@ namespace {
 
 TEST(recipe, gap_junctions)
 {
-    arb::proc_allocation resources;
-    if (auto nt = arbenv::get_env_num_threads()) {
-        resources.num_threads = nt;
-    }
-    else {
-        resources.num_threads = arbenv::thread_concurrency();
-    }
-    auto context = make_context(resources);
+    auto context = make_context({arbenv::default_concurrency(), -1});
 
     auto cell_0 = custom_cell(0, 0, 3);
     auto cell_1 = custom_cell(0, 0, 3);
@@ -140,14 +133,7 @@ TEST(recipe, gap_junctions)
 
 TEST(recipe, connections)
 {
-    arb::proc_allocation resources;
-    if (auto nt = arbenv::get_env_num_threads()) {
-        resources.num_threads = nt;
-    }
-    else {
-        resources.num_threads = arbenv::thread_concurrency();
-    }
-    auto context = make_context(resources);
+    auto context = make_context({arbenv::default_concurrency(), -1});
 
     auto cell_0 = custom_cell(1, 2, 0);
     auto cell_1 = custom_cell(2, 1, 0);
@@ -211,14 +197,7 @@ TEST(recipe, connections)
 }
 
 TEST(recipe, event_generators) {
-    arb::proc_allocation resources;
-    if (auto nt = arbenv::get_env_num_threads()) {
-        resources.num_threads = nt;
-    }
-    else {
-        resources.num_threads = arbenv::thread_concurrency();
-    }
-    auto context = make_context(resources);
+    auto context = make_context({arbenv::default_concurrency(), -1});
 
     auto cell_0 = custom_cell(1, 2, 0);
     auto cell_1 = custom_cell(2, 1, 0);
diff --git a/test/unit/test_spike_store.cpp b/test/unit/test_spike_store.cpp
index 6cb37be8b06bf356a4980999f26417ec429ebd34..17fa46b93e0dbf4936175a0670604c34abeb62a5 100644
--- a/test/unit/test_spike_store.cpp
+++ b/test/unit/test_spike_store.cpp
@@ -1,7 +1,7 @@
 #include "../gtest.h"
 
 #include <arbor/spike.hpp>
-#include <arborenv/concurrency.hpp>
+#include <arborenv/default_env.hpp>
 
 #include "execution_context.hpp"
 #include "thread_private_spike_store.hpp"
@@ -12,15 +12,7 @@ TEST(spike_store, insert)
 {
     using store_type = arb::thread_private_spike_store;
 
-    arb::proc_allocation resources;
-    if (auto nt = arbenv::get_env_num_threads()) {
-        resources.num_threads = nt;
-    }
-    else {
-        resources.num_threads = arbenv::thread_concurrency();
-    }
-
-    arb::execution_context context(resources);
+    arb::execution_context context({arbenv::default_concurrency(), -1});
     store_type store(context.thread_pool);
 
     // insert 3 spike events and check that they were inserted correctly
@@ -65,15 +57,7 @@ TEST(spike_store, clear)
 {
     using store_type = arb::thread_private_spike_store;
 
-    arb::proc_allocation resources;
-    if (auto nt = arbenv::get_env_num_threads()) {
-        resources.num_threads = nt;
-    }
-    else {
-        resources.num_threads = arbenv::thread_concurrency();
-    }
-
-    arb::execution_context context(resources);
+    arb::execution_context context({arbenv::default_concurrency(), -1});
     store_type store(context.thread_pool);
 
     // insert 3 spike events
@@ -89,15 +73,7 @@ TEST(spike_store, gather)
 {
     using store_type = arb::thread_private_spike_store;
 
-    arb::proc_allocation resources;
-    if (auto nt = arbenv::get_env_num_threads()) {
-        resources.num_threads = nt;
-    }
-    else {
-        resources.num_threads = arbenv::thread_concurrency();
-    }
-
-    arb::execution_context context(resources);
+    arb::execution_context context({arbenv::default_concurrency(), -1});
     store_type store(context.thread_pool);
 
     std::vector<spike> spikes =