Repository: esi-neuroscience/acme (language: python)

Issue:
Release preps
<!--
Copyright (c) 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
in Cooperation with Max Planck Society
SPDX-License-Identifier: CC-BY-NC-SA-1.0
-->

Summary
-------
- Better support for IBM POWER machines
- New POWER-based CI job
- Hardened tests
- Improved docs

Author Guidelines
-----------------

- [ ] Is the change set **< 400 lines**? **No**
- [x] Is the code running locally **and** on HPC infrastructure?
- [x] Is the code running on all supported platforms (macOS, Linux and Windows)?

Reviewer Checklist
------------------

- [x] Are testing routines present?
- [x] Do objects in the global package namespace perform proper parsing of their input?
- [x] Are all docstrings complete and accurate?
- [x] Are proposed changes tracked in `CHANGELOG.md`?



Discussion / hints:

Fix patch:
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 8933e7b..337a5dd 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,8 +1,8 @@
 #
-# Copyright (c) 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
 # in Cooperation with Max Planck Society
 #
-# SPDX-License-Identifier: CC0-1.0
+# SPDX-License-Identifier: BSD-3-Clause
 #
 
 stages:
@@ -11,7 +11,7 @@ stages:
     - upload
     - deploy
 
-intellinux:
+linux_x86:
     stage: tox
     allow_failure: true
     tags:
@@ -26,11 +26,10 @@ intellinux:
     script:
     - source $HOME/miniconda/etc/profile.d/conda.sh
     - conda clean --all -y
-    - conda env update -f acme.yml --prune
     - conda activate acme
     - tox
 
-powerlinux:
+linux_ppc64le:
     stage: tox
     allow_failure: true
     tags:
@@ -44,11 +43,10 @@ powerlinux:
     script:
     - source /opt/conda/etc/profile.d/conda.sh
     - conda clean --all -y
-    - conda env update -f acme.yml --prune
     - conda activate acme
     - tox -p 0
 
-intelwin:
+win_x86:
     stage: tox
     allow_failure: true
     tags:
@@ -60,12 +58,12 @@ intelwin:
         PYTEST_ADDOPTS: "--color=yes --tb=short --verbose"
         GIT_FETCH_EXTRA_FLAGS: --tags
     script:
+    - conda init cmd.exe
     - conda clean --all -y
-    - conda env update -f acme.yml --prune
-    - conda.bat activate acme
+    - conda activate acme
     - tox
 
-m1macos:
+macos_m1:
     stage: tox
     allow_failure: true
     tags:
@@ -79,11 +77,10 @@ m1macos:
     script:
     - source /opt/conda/etc/profile.d/conda.sh
     - conda clean --all -y
-    - conda env update -f acme.yml --prune
     - conda activate acme
-    - tox -p 0
+    - tox -p
 
-slurmtest:
+slurm_x86:
     stage: slurm
     allow_failure: true
     tags:
@@ -102,10 +99,30 @@ slurmtest:
     - export PYTHONPATH=$CI_PROJECT_DIR
     - rm -rf $CI_PROJECT_DIR/acme/__pycache__
     - rm -rf $CI_PROJECT_DIR/acme/tests/__pycache__
-    - srun -p DEV --mem=4000m -c 4 pytest
+    - srun -p DEV --mem=8000m -c 1 pytest
     - codecov -t ${CODECOV_TOKEN}
 
-pypitest:
+slurm_ppc64le:
+    stage: slurm
+    allow_failure: true
+    tags:
+    - slurm-ppc
+    only:
+    - main
+    - dev
+    variables:
+        PYTEST_ADDOPTS: "--color=yes --tb=short --verbose"
+        GIT_FETCH_EXTRA_FLAGS: --tags
+    script:
+    - source /opt/conda/etc/profile.d/conda.sh
+    - conda env update -f acme.yml --prune
+    - conda activate acme
+    - export PYTHONPATH=$CI_PROJECT_DIR
+    - rm -rf $CI_PROJECT_DIR/acme/__pycache__
+    - rm -rf $CI_PROJECT_DIR/acme/tests/__pycache__
+    - srun -p E880 --mem=8000m -c 4 pytest
+
+pypi_test:
     stage: upload
     only:
     - main
@@ -136,7 +153,7 @@ pypitest:
     - conda deactivate
     - conda remove --yes --name piptest --all
 
-pypideploy:
+pypi_deploy:
     stage: deploy
     when: manual
     only:
@@ -149,7 +166,6 @@ pypideploy:
     - source $HOME/miniconda/etc/profile.d/conda.sh
     - conda update --yes conda
     - conda clean --all -y
-    - conda env update -f acme.yml --prune
     - conda activate acme
     - conda install --yes twine keyring rfc3986
     - conda update --yes twine keyring rfc3986
diff --git a/.readthedocs.yml b/.readthedocs.yaml
similarity index 80%
rename from .readthedocs.yml
rename to .readthedocs.yaml
index 4efaf37..02d7f47 100644
--- a/.readthedocs.yml
+++ b/.readthedocs.yaml
@@ -11,6 +11,12 @@
 # Required
 version: 2
 
+# Set the version of Python and other tools you might need
+build:
+  os: ubuntu-22.04
+  tools:
+    python: "mambaforge-22.9"
+
 # Build documentation in the docs/ directory with Sphinx
 sphinx:
   configuration: doc/source/conf.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1efa7fb..ed10723 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,19 +8,80 @@
 All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
 
-## [Unreleased]
+## [2023.12] - 2023-12-6
+Better support for non-x86 micro-architectures. On the ESI HPC cluster,
+the convenience function `esi_cluster_setup` now transparently works with the
+local `"E880"` partition comprising our IBM POWER E880 servers. Similar to
+the x86 nodes, a simple
+
+```python
+client = esi_cluster_setup(n_workers=10, partition="E880")
+```
+
+is enough to launch ten SLURM workers each equipped with four POWER8 cores
+and 16 GB RAM by default. Similarly, ACME's automatic partition selection has been
+extended to also support workloads running inside the `"E880"` partition.
+Nonetheless, `esi_cluster_setup` did not only get simpler to use but now also
+comes with more (still completely optional) customization settings:
+the new keyword `cores_per_worker` can be used together with `mem_per_worker`
+and `job_extra` to create specialized computing clients custom-tailored
+to specific workload requirements, e.g.,
+
+```python
+client = esi_cluster_setup(n_workers=10,
+                           cores_per_worker=3,
+                           mem_per_worker="12GB",
+                           job_extra=["--job-name='myjob'"],
+                           partition="E880")
+```
+
+For more see [Advanced Usage and Customization](https://esi-acme.readthedocs.io/en/latest/advanced_usage.html)
+
 ### NEW
+- New keyword `cores_per_worker` in `esi_cluster_setup` to explicitly set
+  the core-count of SLURM workers.
+- Extended functionality of ACME's partition auto-selection on the ESI
+  HPC cluster to include IBM POWER machines in the "E880" partition
 - Added new "Tutorials" section in documentation
 - Added new tutorial on using ACME for parallel evaluation of classifier
   accuracy (Thanks to @timnaher, cf #53)
 - Added new tutorial on using ACME for parallel neural net model evaluation
   (Thanks to @timnaher, cf #53)
+- Added type-hints following PEP 484 to support static code analyzers
+  (e.g., `mypy`) and clarify type conventions in internal functions with
+  "sparse" docstrings.
 
 ### CHANGED
+- To avoid dubious (and hard to debug) errors, `esi_cluster_setup` now
+  checks the micro-architecture of the submitting host against the chosen
+  partition. This avoids accidental start attempts of ppc64le SLURM jobs
+  from inside an x86_64 Python interpreter and vice versa.
+
 ### REMOVED
+- The `partition` keyword in `esi_cluster_setup` does not have a default
+  value any more (the old default of "8GBXS" was inappropriate most of
+  the time)
+- The (undocumented) "anonymous" keyword `n_cores` of `esi_cluster_setup`
+  has been removed in favor of the explicit `cores_per_worker` (now also
+  visible in the API). Just like `n_cores`, setting the new `cores_per_worker`
+  parameter is still optional: by default, `esi_cluster_setup` derives
+  core-count from `DefMemPerCPU` and the chosen value of `mem_per_worker`.
+- In `slurm_cluster_setup`, do not use `DefMemPerCPU` as fallback substitute
+  in case `MaxMemPerCPU` is not defined for chosen partition (may be overly
+  restrictive on requested memory settings)
+
 ### DEPRECATED
+- Using `start_client` in `local_cluster_setup` does not have any effect
+  any more: starting a dask `LocalCluster` always starts a client.
+
 ### FIXED
 - fixed partition bug ``run_tests.sh`` (Thanks to @timnaher, cf #53)
+- simplified and fixed interactive user queries: use the builtin `select`
+  module in everything but Jupyter and rely on the `input` module inside
+  notebooks.
+- clarified docstring discussing `result_dtype`: must not be `None` but
+  `str` (still defaults to "float")
+- numerous corrections of errata/outdated information in docstrings
 
 ## [2023.4] - 2023-04-14
 Re-designed ACME's logs and command line output.
diff --git a/CITATION.cff b/CITATION.cff
index bbfe1e8..2aa6dd4 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -15,7 +15,7 @@ authors:
   given-names: Joscha Tapani
   orcid: https://orcid.org/0000-0001-6233-1866
 cff-version: 1.1.0
-date-released: '2023-04-17'
+date-released: '2023-12-06'
 keywords:
 - high-performance computing
 - parallel computing
@@ -25,4 +25,4 @@ message: If you use this software, please cite it based on metadata found in thi
   itself used as the parallelization engine of SyNCoPy.
 repository-code: https://github.com/esi-neuroscience/acme
 title: 'ACME: Asynchronous Computing Made ESI'
-version: '2023.4'
+version: '2023.12'
diff --git a/MANIFEST.in b/MANIFEST.in
index 596e269..ee57aea 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -10,5 +10,8 @@ exclude .gitignore
 exclude .gitlab-ci.yml
 exclude MANIFEST.in
 exclude .readthedocs.yml
+exclude acme/tests/local_acme.py
+exclude acme/tests/no_slurm.sh
+exclude acme/tests/run_tests.*
 recursive-exclude .github *
 recursive-exclude doc *
diff --git a/README.md b/README.md
index ba5efd6..25268de 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,22 @@ main: [![tests](https://github.com/esi-neuroscience/acme/actions/workflows/tests
 dev: [![tests](https://github.com/esi-neuroscience/acme/actions/workflows/tests_workflow.yml/badge.svg?branch=dev)](https://github.com/esi-neuroscience/acme/actions/workflows/tests_workflow.yml)
 [![codecov](https://codecov.io/gh/esi-neuroscience/acme/branch/dev/graph/badge.svg?token=LCB2RPBQJG)](https://codecov.io/gh/esi-neuroscience/acme)
 
+## Table of Contents
+
+1. [Summary](#summary)
+1. [Installation](#installation)
+1. [Usage](#usage)
+   - [Basic Examples](#basic-examples)
+   - [Intermediate Examples](#intermediate-examples)
+   - [Advanced Use](#advanced-use)
+1. [Handling Results](#handling-results)
+   - [Load Results From Files](#load-results-from-files)
+   - [Collect Results in Single HDF5 Dataset](#collect-results-in-single-hdf5-dataset)
+   - [Collect Results in Local Memory](#collect-results-in-local-memory)
+1. [Debugging](#debugging)
+1. [Documentation and Contact](#documentation-and-contact)
+   - [Resources](#resources)
+
 ## Summary
 
 The objective of ACME (pronounced *"ak-mee"*) is to provide easy-to-use
@@ -130,9 +146,9 @@ with pmap as p:
 
 For more information see [Reuse Worker Clients](https://esi-acme.readthedocs.io/en/latest/userguide.html#reuse-worker-clients)
 
-## Handling results
+## Handling Results
 
-### Load results from files
+### Load Results From Files
 
 By default, results are saved to disk in HDF5 format and can be accessed using
 the `results_container` attribute of `ParallelMap`:
@@ -159,7 +175,7 @@ with h5py.File(pmap.results_container, "r") as h5f:
 
 See also [Where Are My Results?](https://esi-acme.readthedocs.io/en/latest/userguide.html#where-are-my-results)
 
-### Collect results in single HDF5 dataset
+### Collect Results in Single HDF5 Dataset
 
 If possible, results can be slotted into a single HDF5 dataset:
 
@@ -183,7 +199,7 @@ with h5py.File(pmap.results_container, "r") as h5f:
 More examples can be found in
 [Collect Results in Single Dataset](https://esi-acme.readthedocs.io/en/latest/userguide.html#collect-results-in-single-dataset)
 
-### Collect results in local memory
+### Collect Results in Local Memory
 
 This is possible but not recommended.
 
@@ -224,3 +240,11 @@ To report bugs or ask questions please use our
 [GitHub issue tracker](https://github.com/esi-neuroscience/acme/issues).
 More usage details and background information is available in our
 [online documentation](https://esi-acme.readthedocs.io).
+
+### Resources
+
+- [ACME Presentation](https://pantaray.github.io/RSE23-talk/)
+  at [deRSE23 - Conference for Research Software Engineering in Germany](https://de-rse23.sciencesconf.org/)
+- [ACME Demo](https://esi-neuroscience.github.io/acme-demo/) presented at the 4th annual Data Scientist Community Meeting
+- [ACME Tutorials](https://esi-acme.readthedocs.io/en/latest/tutorials.html)
+- [ACME FAQ](https://esi-acme.readthedocs.io/en/latest/troubleshooting_faq.html)
diff --git a/RELEASE.md b/RELEASE.md
new file mode 100644
index 0000000..e2144e1
--- /dev/null
+++ b/RELEASE.md
@@ -0,0 +1,128 @@
+<!--
+Copyright (c) 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+in Cooperation with Max Planck Society
+SPDX-License-Identifier: CC-BY-NC-SA-1.0
+-->
+
+# Releasing a New ACME Version
+
+The instructions collected here are intended to help with preparing a
+new ACME release. This file is mainly intended for internal use, for a
+detailed guide on how to contribute to ACME, please see our
+[Contributing Guide](https://esi-acme.readthedocs.io/en/latest/contributing.html)
+
+## Prerequisites
+
+1. On your development machine, set up a new conda environment with the
+   most recent Python version intended to be supported
+
+   ```bash
+   conda create -n acme-py11 python=3.11
+   ```
+
+1. Update dependencies: open [setup.cfg](./setup.cfg) and install the
+   most recent versions (not necessarily those stated) of all listed dependencies
+
+   ```bash
+   conda install dask "dask-jobqueue>=0.8" h5py numpy "tqdm>=4.31" pytest-cov ipdb ipython mypy "scipy>= 1.5,<2.0" tox
+   ```
+
+1. Run the test-suite locally
+
+   ```bash
+   cd /path/to/acme-repo/acme/tests
+   ./run_tests.sh pytest
+   ```
+
+1. Update testing environment in [tox.ini](./tox.ini) and run tox locally
+   (**IMPORTANT** this also tests if ACME can be installed via pip!)
+
+   ```bash
+   cd /path/to/acme-repo
+   tox
+   ```
+
+1. Run mypy static type checker locally
+
+   ```bash
+   cd /path/to/acme-repo
+   mypy acme --allow-redefinition
+   ```
+
+1. Export your environment and re-recreate it on an x86 ESI HPC cluster node:
+
+   ```bash
+   conda env export --from-history > acmepy11.yml
+   scp acmepy11.yml esi-svhpc2:~/
+   ssh esi-svhpc2
+   module load conda
+   conda env create --file acmepy11.yml
+   ```
+
+   Create an identical environment (append "-ppc" to its name) on a ppc64le
+   node:
+
+   ```bash
+   ssh hub
+   module load conda
+   conda env create --file acmepy11.yml
+   ```
+
+1. Run ACME's test-suite on both architectures
+
+   ```bash
+   ssh {hub,esi-svhpc2}
+   module load conda
+   conda activate acme-py11{-ppc}
+   cd /path/to/acme-repo/acme/tests
+   ./run_tests.sh pytest
+   ```
+
+If all tests are passing, merge changes into ``[dev]`` branch.
+
+## Deployment
+
+> Ensure you're working in ``[dev]``, not ``[main]``!
+
+1. Update dependencies/supported Python version in [setup.cfg](./setup.cfg)
+1. Update build-system requirements in [pyproject.toml](./pyproject.toml)
+1. Bump version number in [setup.py](./setup.py)
+1. Prepare release notes in [CHANGELOG.md](./CHANGELOG.md)
+1. Force-update environment file [acme.yml](./acme.yml) as well as citation
+   resource [CITATION.cff](./CITATION.cff):
+
+   ```bash
+   python setup.py --version
+   ```
+
+1. Check proper licensing of all files (errors in [setup.py](./setup.py)
+   and [CITATION.cff](./CITATION.cff) can be ignored)
+
+   ```bash
+   reuse lint
+   ```
+
+1. Create a release tag
+
+   ```bash
+   git tag -a "202x.y" -m "202x.y"
+   git push --tags
+   ```
+
+Finally, open a PR into ``[main]``. Once merged, wait for the CI pipeline
+to finish and click the play button to publish to PyPi.
+
+## Post-Release Cleanup
+
+1. Checkout ``[dev]`` branch
+1. Prepare next `[Unreleased]` section with pre-defined headings in
+   `CHANGELOG.md` for next release:
+
+   ```text
+   ## [Unreleased]
+   ### NEW
+   ### CHANGED
+   ### REMOVED
+   ### DEPRECATED
+   ### FIXED
+   ```
diff --git a/SECURITY.md b/SECURITY.md
index 9a9ec31..3a307b5 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,3 +1,9 @@
+<!--
+Copyright (c) 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+in Cooperation with Max Planck Society
+SPDX-License-Identifier: CC-BY-NC-SA-1.0
+-->
+
 # Security Policy
 
 ## Reporting a Vulnerability
diff --git a/acme.yml b/acme.yml
index 30e1309..52ff1af 100644
--- a/acme.yml
+++ b/acme.yml
@@ -3,33 +3,37 @@
 #
 # SPDX-License-Identifier: CC0-1.0
 #
-# This file was auto-generated by setup.py on 17/04/2023 at 10:59:16. 
+# This file was auto-generated by setup.py on 06/12/2023 at 14:52:37. 
 # Do not edit, all of your changes will be overwritten. 
 channels:
 - defaults
 - conda-forge
 dependencies:
+- cloudpickle <= 2.2.1
 - colorama
-- dask >= 2021.09.0, < 2023.4
+- dask >= 2021.09.0, <= 2023.12
 - dask-jobqueue >= 0.8, < 0.9
 - h5py < 4
 - numpy >= 1.0, < 2.0
+- msgpack-python < 1.0.7
+- toolz >= 0.12
 - tqdm >= 4.31
 - ipdb
-- ipython
+- ipython <= 8.15
 - scipy >= 1.5, < 2.0
+- mypy
 - numpydoc
 - pytest-cov
+- reuse
 - sphinx < 6
 - sphinx_bootstrap_theme
 - sphinx-tabs
 - tox
 - pyyaml
 - setuptools
-- setuptools_scm
 - toml
 - wheel
-- python <3.11,>=3.7
+- python <3.13,>=3.7
 - pip
 - pip:
   - sphinx_automodapi
diff --git a/acme/__init__.py b/acme/__init__.py
index afac333..054a478 100644
--- a/acme/__init__.py
+++ b/acme/__init__.py
@@ -10,19 +10,19 @@
 # Builtin/3rd party package imports
 import subprocess
 import warnings
-import inspect
 import sys
-from pkg_resources import get_distribution, DistributionNotFound
+from importlib.metadata import version, PackageNotFoundError
+from typing import List
 
 # Get package version: either via meta-information from egg or via latest git commit
 try:
-    __version__ = get_distribution("esi-acme").version
-except DistributionNotFound:
+    __version__ = version("esi-acme")
+except PackageNotFoundError:
     proc = subprocess.Popen("git describe --always",
                             stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                             text=True, shell=True)
     out, err = proc.communicate()
-    if proc.returncode != 0:
+    if proc.returncode != 0:                                            # pragma: no cover
         proc = subprocess.Popen("git rev-parse HEAD:acme/__init__.py",
                                 stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                                 text=True, shell=True)
@@ -30,7 +30,7 @@
         if proc.returncode != 0:
             msg = "<ACME> Package is not installed in site-packages nor cloned via git. " +\
                 "Please consider obtaining ACME sources from supported channels. "
-            warnings.showwarning(msg, ImportWarning, __file__, inspect.currentframe().f_lineno)
+            warnings.warn(msg)
             out = "-999"
     __version__ = out.rstrip("\n")
 
@@ -45,6 +45,7 @@
 warnings.simplefilter(action='ignore', category=FutureWarning)
 
 # Import local modules
+from . import frontend, backend, shared, dask_helpers
 from .frontend import *
 from .backend import *
 from .shared import *
@@ -57,15 +58,15 @@
 # Override default exception handler (take care of Jupyter's Exception handling)
 from .shared import ctrlc_catcher
 try:
-    ipy = get_ipython()
+    ipy = get_ipython()                                                             # type: ignore
     import IPython
     ipy.ipyTBshower = IPython.core.interactiveshell.InteractiveShell.showtraceback
-    IPython.core.interactiveshell.InteractiveShell.showtraceback = ctrlc_catcher
+    IPython.core.interactiveshell.InteractiveShell.showtraceback = ctrlc_catcher    # type: ignore
 except NameError:
     sys.excepthook = ctrlc_catcher
 
 # Manage user-exposed namespace imports
-__all__ = []
+__all__: List[str] = []
 __all__.extend(frontend.__all__)
 __all__.extend(backend.__all__)
 __all__.extend(shared.__all__)
diff --git a/acme/backend.py b/acme/backend.py
index 7282b07..881d6c0 100644
--- a/acme/backend.py
+++ b/acme/backend.py
@@ -19,9 +19,9 @@
 import sys
 import glob
 import shutil
+import functools
 import pickle
 import logging
-import functools
 import multiprocessing
 import psutil
 import tqdm
@@ -30,6 +30,8 @@
 import dask.distributed as dd
 import dask_jobqueue as dj
 import numpy as np
+from typing import TYPE_CHECKING, Optional, Any, Union, List
+from numpy.typing import ArrayLike
 
 # Local imports
 from . import __path__
@@ -38,10 +40,12 @@
 from .shared import user_yesno, is_esi_node, is_slurm_node
 from .logger import prepare_log
 isSpyModule = False
-if "syncopy" in sys.modules:
+if "syncopy" in sys.modules:            # pragma: no cover
     isSpyModule = True
+if TYPE_CHECKING:                       # pragma: no cover
+    from frontend import ParallelMap
 
-__all__ = ["ACMEdaemon"]
+__all__: List["str"] = ["ACMEdaemon"]
 
 # Fetch logger
 log = logging.getLogger("ACME")
@@ -66,79 +70,79 @@ class ACMEdaemon(object):
 
     def __init__(
         self,
-        pmap,
-        n_workers="auto",
-        write_worker_results=True,
-        output_dir=None,
-        result_shape=None,
-        result_dtype="float",
-        single_file=False,
-        write_pickle=False,
-        dryrun=False,
-        partition="auto",
-        mem_per_worker="auto",
-        setup_timeout=60,
-        setup_interactive=True,
-        stop_client="auto",
-        verbose=None,
-        logfile=None):
+        pmap: "ParallelMap",
+        n_workers: Union[int, str] = "auto",
+        write_worker_results: bool = True,
+        output_dir: Optional[str] = None,
+        result_shape: Optional[tuple[Optional[int], ...]] = None,
+        result_dtype: str = "float",
+        single_file: bool = False,
+        write_pickle: bool = False,
+        dryrun: bool = False,
+        partition: str = "auto",
+        mem_per_worker: str = "auto",
+        setup_timeout: int = 60,
+        setup_interactive: bool = True,
+        stop_client: Union[bool, str] = "auto",
+        verbose: Optional[bool] = None,
+        logfile: Optional[Union[bool, str]] = None) -> None:
         """
         Manager class for performing concurrent user function calls
 
         Parameters
         ----------
-        pmap : :class:~`acme.ParallelMap` context manager
+        pmap : :class:`~acme.ParallelMap` context manager
             By default, `:class:~`acme.ACMEDaemon` assumes  that that
-            the provided :class:~`acme.ParallelMap` instance has already
+            the provided :class:`~acme.ParallelMap` instance has already
             been properly set up to process `func` (all input arguments parsed and
             properly formatted). All other input arguments of `:class:~`acme.ACMEDaemon`
-            are extracted from the provided :class:~`acme.ParallelMap` instance.
+            are extracted from the provided :class:`~acme.ParallelMap` instance.
         n_workers : int or "auto"
-            Number of SLURM workers (=jobs) to spawn. See :class:~`acme.ParallelMap`
+            Number of SLURM workers (=jobs) to spawn. See :class:`~acme.ParallelMap`
             for details.
         write_worker_results : bool
             If `True`, the return value(s) of `func` is/are saved on disk. See
-            :class:~`acme.ParallelMap` for details.
+            :class:`~acme.ParallelMap` for details.
         output_dir : str or None
             If provided, auto-generated results are stored in the given path. See
-            :class:~`acme.ParallelMap` for details.
+            :class:`~acme.ParallelMap` for details.
         result_shape : tuple or None
             If provided, results are slotted into a dataset/array with layout `result_shape`. See
-            :class:~`acme.ParallelMap` for details.
-        result_dtype : str or None
-            If provided, determines numerical datatype of dataset laid out
-            by `result_shape`. See :class:~`acme.ParallelMap` for details.
+            :class:`~acme.ParallelMap` for details.
+        result_dtype : str
+            Determines numerical datatype of dataset laid out by `result_shape`.
+            See :class:`~acme.ParallelMap` for details.
         single_file : bool
             If `True`, parallel workers write to the same results container. See
-            :class:~`acme.ParallelMap` for details.
+            :class:`~acme.ParallelMap` for details.
         write_pickle : bool
             If `True`, the return value(s) of `func` is/are pickled to disk. See
-            :class:~`acme.ParallelMap` for details.
+            :class:`~acme.ParallelMap` for details.
         dryrun : bool
             If `True`, a dry-run of calling `func` is performed using a single
-            `args`, `kwargs` tuple. See :class:~`acme.ParallelMap` for details.
+            `args`, `kwargs` tuple. See :class:`~acme.ParallelMap` for details.
         partition : str
-            Name of SLURM partition to use. See :class:~`acme.ParallelMap` for details.
+            Name of SLURM partition to use. See :class:`~acme.ParallelMap` for details.
         mem_per_worker : str
-            Memory booking for each SLURM worker. See :class:~`acme.ParallelMap` for details.
+            Memory booking for each SLURM worker. See :class:`~acme.ParallelMap` for details.
         setup_timeout : int
             Timeout period (in seconds) for SLURM workers to come online. See
-            :class:~`acme.ParallelMap` for details.
+            :class:`~acme.ParallelMap` for details.
         setup_interactive : bool
             If `True`, user input is queried in case not enough SLURM workers could
-            be started within `setup_timeout` seconds. See :class:~`acme.ParallelMap`
+            be started within `setup_timeout` seconds. See :class:`~acme.ParallelMap`
             for details.
         stop_client : bool or "auto"
             If `"auto"`, automatically started distributed computing clients
             are shut down at the end of computation, while user-provided clients
-            are left untouched. See :class:~`acme.ParallelMap` for details.
+            are left untouched. See :class:`~acme.ParallelMap` for details.
         verbose : None or bool
             If `None` (default), general run-time information as well as warnings
-            and errors are shown. See :class:~`acme.ParallelMap` for details.
+            and errors are shown. See :class:`~acme.ParallelMap` for details.
         logfile : None or bool or str
             If `None` (default) or `True`, and `write_worker_results` is
             `True`, all run-time information as well as errors and
-            warnings are tracked in a log-file. See :class:~`acme.ParallelMap`
+            warnings are tracked in a log-file. See :class:`~acme.ParallelMap`
             for details.
 
         Returns
@@ -147,8 +151,8 @@ def __init__(
             If `write_worker_results` is `True`, `results` is a list of HDF5 file-names
             containing computed results. If `write_worker_results` is `False`,
             results is a list comprising the actual return values of `func`.
-            If `:class:~`acme.ACMEDaemon` was instantiated by :class:~`acme.ParallelMap`,
-            results are propagated back to :class:~`acme.ParallelMap`.
+            If `:class:~`acme.ACMEDaemon` was instantiated by :class:`~acme.ParallelMap`,
+            results are propagated back to :class:`~acme.ParallelMap`.
 
         See also
         --------
@@ -214,15 +218,16 @@ def __init__(
                             setup_interactive=setup_interactive,
                             stop_client=stop_client)
 
-    def pre_process(self,
-                    verbose,
-                    logfile,
-                    write_worker_results,
-                    output_dir,
-                    result_shape,
-                    result_dtype,
-                    single_file,
-                    write_pickle):
+    def pre_process(
+            self,
+            verbose: Union[bool, None],
+            logfile: Union[bool, str, None],
+            write_worker_results: bool,
+            output_dir: Union[str, None],
+            result_shape: Union[tuple[Optional[int], ...], None],
+            result_dtype: str,
+            single_file: bool,
+            write_pickle: bool) -> None:
         """
         If `write_*` is `True` set up directories for saving output HDF5 containers
         (or pickle files). Warn if results are to be collected in memory
@@ -270,24 +275,24 @@ def pre_process(self,
                 raise ValueError(msg%self.objName)
 
             rShape = list(result_shape)
-            self.stacking_dim = result_shape.index(None)
-            rShape[self.stacking_dim] = self.n_calls
+            self.stacking_dim = result_shape.index(None)                # type: ignore
+            rShape[self.stacking_dim] = self.n_calls                    # type: ignore
             if write_worker_results:
                 self.kwargv["stackingDim"] = [self.stacking_dim]
 
             if not all(isinstance(spec, numbers.Number) for spec in rShape):
                 msg = "%s `result_shape` must only contain numerical values"
                 raise ValueError(msg%self.objName)
-            if any(spec < 0 or int(spec) != spec for spec in rShape):
+            if any(spec < 0 or int(spec) != spec for spec in rShape):   # type: ignore
                 msg = "%s `result_shape` must only contain non-negative integers"
                 raise ValueError(msg%self.objName)
 
-            self.result_shape = tuple(rShape)
+            self.result_shape = tuple(rShape)                           # type: ignore
             msg = "Found `result_shape = %s`. Set stacking dimension to %d"
             log.debug(msg, str(result_shape), self.stacking_dim)
 
             try:
-                self.result_dtype = np.dtype(result_dtype)
+                self.result_dtype = np.dtype(result_dtype)              # type: ignore
             except Exception as exc:
                 msg = "%s `result_dtype` has to be a valid NumPy datatype specification. "
                 msg += "Original error message below:\n%s"
@@ -311,10 +316,10 @@ def pre_process(self,
                         "Results will be collected in memory by caller - this might be slow and can lead " +\
                         "to excessive memory consumption. "
                     log.warning(msg, self.func.__name__)
-                self.collect_results = True
+                self.collect_results = True                             # type: ignore
             else:
                 self.kwargv["taskID"] = self.task_ids
-                self.collect_results = False
+                self.collect_results = False                            # type: ignore
                 msg = "Not collecting results in memory, leaving output " +\
                     "processing to user-provided function"
                 log.debug(msg)
@@ -337,7 +342,7 @@ def pre_process(self,
                     logfile = self.out_dir
                 else:
                     logfile = os.path.dirname(os.path.abspath(inspect.getfile(self.func)))
-                logfile = os.path.join(logfile, "ACME_{func:s}_{date:s}.log")
+                logfile = os.path.join(logfile, "ACME_{func:s}_{date:s}.log")   # type: ignore
                 logfile = logfile.format(func=self.func.__name__,
                                          date=datetime.datetime.now().strftime('%Y%m%d-%H%M%S'))
             else:
@@ -355,11 +360,12 @@ def pre_process(self,
 
         return
 
-    def setup_output(self,
-                     output_dir,
-                     result_shape,
-                     single_file,
-                     write_pickle):
+    def setup_output(
+            self,
+            output_dir: Union[str, None],
+            result_shape: Union[tuple[Optional[int], ...], None],
+            single_file: bool,
+            write_pickle: bool) -> None:
         """
         Local helper for creating output directories and preparing containers
         """
@@ -378,7 +384,7 @@ def setup_output(self,
             # On the ESI cluster, save results on HPC mount, otherwise use location of `func`
             if self.has_slurm:
                 outDir = "/cs/home/{usr:s}/".format(usr=getpass.getuser())
-            else:
+            else:                                                       # pragma: no cover
                 outDir = os.path.dirname(os.path.abspath(inspect.getfile(self.func)))
             outDir = os.path.join(outDir, "ACME_{date:s}")
             outDir = outDir.format(date=datetime.datetime.now().strftime('%Y%m%d-%H%M%S-%f'))
@@ -387,11 +393,11 @@ def setup_output(self,
         # Unless specifically denied by the user, each worker stores results
         # separately with a common container file pointing to the individual
         # by-worker files residing in a "payload" directory
-        self.out_dir = str(outDir)
+        self.out_dir = str(outDir)                                      # type: ignore
         if not single_file and not write_pickle:
             log.debug("Preparing payload directory for HDF5 containers")
             payloadName = "{}_payload".format(self.func.__name__)
-            outputDir = os.path.join(self.out_dir, payloadName)
+            outputDir = os.path.join(self.out_dir, payloadName)         # type: ignore
         else:
             msg = "Either single-file output or pickling was requested. " +\
                 "Not creating payload directory"
@@ -406,7 +412,7 @@ def setup_output(self,
 
         # Re-define or allocate key "taskID" to track concurrent processing results
         self.kwargv["taskID"] = self.task_ids
-        self.collect_results = False
+        self.collect_results = False                                    # type: ignore
 
         # Set up correct file-extension for output files; in case of HDF5
         # containers, prepare "main" file for collecting/symlinking worker results
@@ -415,7 +421,7 @@ def setup_output(self,
             log.debug("Pickling was requested")
         else:
             fExt = "h5"
-            self.results_container = os.path.join(self.out_dir, "{}.h5".format(self.func.__name__))
+            self.results_container = os.path.join(self.out_dir, "{}.h5".format(self.func.__name__))     # type: ignore
             log.debug("Using HDF5 storage %s", self.results_container)
 
         # By default, `results_container` is a collection of links that point to
@@ -465,15 +471,15 @@ def setup_output(self,
                 else:
 
                     # Assemble virtual dataset
-                    ShapeSource = list(self.result_shape)
+                    ShapeSource = list(self.result_shape)               # type: ignore
                     ShapeSource.pop(self.stacking_dim)
                     ShapeSource = tuple(ShapeSource)
-                    layout = h5py.VirtualLayout(shape=self.result_shape, dtype=self.result_dtype)
-                    idx = [slice(None)] * len(self.result_shape)
+                    layout = h5py.VirtualLayout(shape=self.result_shape, dtype=self.result_dtype)   # type: ignore
+                    idx = [slice(None)] * len(self.result_shape)        # type: ignore
                     msg = "Created virtual dataset result_0' with shape " +\
                         "%s in results container"
                     for i, fname in enumerate(self.kwargv["outFile"]):
-                        idx[self.stacking_dim] = i
+                        idx[self.stacking_dim] = i                      # type: ignore
                         relPath = os.path.join(payloadName, os.path.basename(fname))
                         vsource = h5py.VirtualSource(fname, "result_0", shape=ShapeSource)
                         layout[tuple(idx)] = vsource
@@ -486,7 +492,7 @@ def setup_output(self,
 
         # Wrap the user-provided func and distribute it across workers
         self.kwargv["userFunc"] = [self.func]
-        self.acme_func = self.func_wrapper
+        self.acme_func = self.func_wrapper                              # type: ignore
         log.debug("Wrapping user-provided function inside func_wrapper")
 
         # Finally, attach verbosity flag to enable logging inside wrapper
@@ -494,13 +500,15 @@ def setup_output(self,
 
         return
 
-    def perform_dryrun(self, setup_interactive):
+    def perform_dryrun(
+            self,
+            setup_interactive: bool) -> bool:
         """
         Execute user function with one prepared randomly picked args, kwargs combo
         """
 
         # Let helper randomly pick a single scheduled job and prepare corresponding args + kwargs
-        [dryRunIdx], [dryRunArgs], [dryRunKwargs] = self._dryrun_setup(n_runs=1)
+        [dryRunIdx], [dryRunArgs], [dryRunKwargs] = self._dryrun_setup(n_runs=1)    # type: ignore
 
         # Create log entry
         msg = "Performing a single dry-run of %s simulating randomly " +\
@@ -512,7 +520,7 @@ def perform_dryrun(self, setup_interactive):
         log.debug("Initial memory consumption estimate: %3.f MB", mem0)
         log.debug("Starting dryrun")
         tic = time.perf_counter()
-        self.acme_func(*dryRunArgs, **dryRunKwargs)
+        self.acme_func(*dryRunArgs, **dryRunKwargs)                     # type: ignore
         toc = time.perf_counter()
         log.debug("Finished dryrun")
         mem1 = psutil.Process().memory_info().rss / 1024 ** 2
@@ -547,7 +555,9 @@ def perform_dryrun(self, setup_interactive):
                 goOn = False
         return goOn
 
-    def _dryrun_setup(self, n_runs=None):
+    def _dryrun_setup(
+            self,
+            n_runs: Optional[int] = None) -> tuple[ArrayLike, List, List]:
         """
         Pick scheduled job(s) at random and extract corresponding (already prepared!) args + kwargs
         """
@@ -568,13 +578,13 @@ def _dryrun_setup(self, n_runs=None):
         return dryRunIdx, dryRunArgs, dryRunKwargs
 
     def prepare_client(
-        self,
-        n_workers="auto",
-        partition="auto",
-        mem_per_worker="auto",
-        setup_timeout=180,
-        setup_interactive=True,
-        stop_client="auto"):
+            self,
+            n_workers: Union[int, str] = "auto",
+            partition: str = "auto",
+            mem_per_worker: str = "auto",
+            setup_timeout: int = 60,
+            setup_interactive: bool = True,
+            stop_client: Union[bool, str] = "auto") -> None:
         """
         Setup or fetch dask distributed processing client. Depending on available
         hardware, either start a local multi-processing client or launch a
@@ -591,21 +601,21 @@ def prepare_client(
             if stop_client != "auto":
                 raise ValueError(msg%(self.objName, stop_client))
         elif isinstance(stop_client, bool):
-            self.stop_client = stop_client
+            self.stop_client = stop_client                              # type: ignore
         else:
             raise TypeError(msg%(self.objName, str(type(stop_client))))
         log.debug("Using `stop_client = %s`", str(stop_client))
 
         # Check if a dask client is already running
         try:
-            self.client = dd.get_client()
+            self.client = dd.get_client()                               # type: ignore
             log.debug("Detected running client %s", str(self.client))
             if stop_client == "auto":
-                self.stop_client = False
+                self.stop_client = False                                # type: ignore
                 msg = "%s Changing `stop_client` from `'auto'` to `False` " +\
                     "to not terminate external client"
                 log.debug(msg)
-            self.n_workers = count_online_workers(self.client.cluster)
+            self.n_workers = count_online_workers(self.client.cluster)  # type: ignore
             log.debug("Found %d alive workers in the client", self.n_workers)
             msg = "Attaching to parallel computing client %s"
             log.info(msg%(str(self.client)))
@@ -614,16 +624,29 @@ def prepare_client(
             msg = "No running client detected, preparing to start a new one"
             log.debug(msg)
             if stop_client == "auto":
-                self.stop_client = True
+                self.stop_client = True                                 # type: ignore
                 msg = "Changing `stop_client` from `'auto'` to `True` " +\
                     "to clean up client started by `ParallelMap`"
                 log.debug(msg)
 
+            # If `n_workers` is `"auto`, set `n_workers = n_calls` (default)
+            msg = "%s `n_workers` has to be 'auto' or an integer >= 1, not %s"
+            if isinstance(n_workers, str):
+                if n_workers != "auto":
+                    raise ValueError(msg%(self.objName, n_workers))
+                if self.has_slurm:
+                    n_workers = self.n_calls
+                else:
+                    n_workers = None
+                log.debug("Changing `n_workers` from `'auto'` to %s", str(n_workers))
+            log.debug("Using provided `n_workers = %d` to start client", n_workers)
+
         # If things are running locally, simply fire up a dask-distributed client,
         # otherwise go through the motions of preparing a full worker cluster
-        if not self.has_slurm:
+        if not self.has_slurm:                                          # pragma: no cover
+
             log.debug("SLURM not found, Calling `local_cluster_setup`")
-            self.client = local_cluster_setup(interactive=False)
+            self.client = local_cluster_setup(n_workers=n_workers, interactive=False)        # type: ignore
 
         else:
 
@@ -638,38 +661,29 @@ def prepare_client(
                     msg = "Automatic SLURM partition selection is experimental"
                     log.warning(msg)
                     mem_per_worker = self.estimate_memuse()
-                else:
+                else:                                                   # pragma: no cover
                     err = "Automatic SLURM partition selection currently only available " +\
                         "on the ESI HPC cluster. "
                     log.error(err)
 
-            # If `n_workers` is `"auto`, set `n_workers = n_calls` (default)
-            msg = "%s `n_workers` has to be 'auto' or an integer >= 2, not %s"
-            if isinstance(n_workers, str):
-                if n_workers != "auto":
-                    raise ValueError(msg%(self.objName, n_workers))
-                n_workers = self.n_calls
-                log.debug("Changing `n_workers` from `'auto'` to %d", n_workers)
-            log.debug("Using provided `n_workers = %d` to start client", n_workers)
-
             # All set, remaining input processing is done by respective `*_cluster_setup` routines
             if is_esi_node():
                 msg = "Running on ESI compute node, Calling `esi_cluster_setup`"
                 log.debug(msg)
-                self.client = esi_cluster_setup(partition=partition, n_workers=n_workers,
+                self.client = esi_cluster_setup(partition=partition, n_workers=n_workers,               # type: ignore
                                                 mem_per_worker=mem_per_worker, timeout=setup_timeout,
                                                 interactive=setup_interactive, start_client=True)
 
             # Unknown cluster node, use vanilla config
-            else:
+            else:                                                       # pragma: no cover
                 wrng = "Cluster node %s not recognized. Falling back to vanilla " +\
                     "SLURM setup allocating one worker and one core per worker"
                 log.warning(wrng%(socket.getfqdn()))
                 processes_per_worker = 1
                 n_cores = 1
-                self.client = slurm_cluster_setup(partition=partition,
+                self.client = slurm_cluster_setup(partition=partition,                                  # type: ignore
                                                   n_cores=n_cores,
-                                                  n_workers=n_workers,
+                                                  n_workers=n_workers,                                  # type: ignore
                                                   processes_per_worker=processes_per_worker,
                                                   mem_per_worker=mem_per_worker,
                                                   n_workers_startup=1,
@@ -681,12 +695,12 @@ def prepare_client(
                                                   invalid_partitions=[])
 
             # If startup is aborted by user, get outta here
-            if self.client is None:
+            if self.client is None:                                     # pragma: no cover
                 msg = "%s Could not start distributed computing client. "
                 raise ConnectionAbortedError(msg%(self.objName))
 
         # Set `n_workers` to no. of active workers in the initialized cluster
-        self.n_workers = len(self.client.cluster.workers)
+        self.n_workers = len(self.client.cluster.workers)               # type: ignore
         log.debug("Setting `n_workers = %d` based on active workers in %s",
                   self.n_workers, str(self.client))
 
@@ -695,11 +709,11 @@ def prepare_client(
         if self.kwargv.get("singleFile") is not None:
             msg = "Initializing distributed lock for writing to single shared results container"
             log.debug(msg)
-            dd.lock.Lock(name=os.path.basename(self.results_container))
+            dd.lock.Lock(name=os.path.basename(self.results_container)) # type: ignore
 
         return
 
-    def estimate_memuse(self):
+    def estimate_memuse(self) -> str:
         """
         A brute-force guessing approach to determine memory consumption of provided
         workload
@@ -720,15 +734,15 @@ def estimate_memuse(self):
         # consumption estimates (in GB)
         runTime = 30
         memPerSec = np.zeros((runTime,))
-        memPerJob = np.zeros((len(dryRunIdx),))
+        memPerJob = np.zeros((len(dryRunIdx),))                         # type: ignore
 
         # Adequately warn about this heuristic gymnastics...
         log.info("Estimating memory footprint of %s", self.func.__name__)
 
         msg = "Running %d random workers evaluating %s for max. %d seconds"
-        log.debug(msg%(len(dryRunIdx), self.func.__name__, runTime))
+        log.debug(msg%(len(dryRunIdx), self.func.__name__, runTime))    # type: ignore
         wmsg = "Launching worker #{wrknum:d}"
-        for i, idx in enumerate(dryRunIdx):
+        for i, idx in enumerate(dryRunIdx):                             # type: ignore
 
             # Set up dedicated process to execute user-provided function w/allocated args/kwargs
             proc = multiprocessing.Process(target=self.acme_func,
@@ -760,14 +774,16 @@ def estimate_memuse(self):
 
         # Communicate results
         msg = "Estimated memory consumption across %d runs is %3.2f GB "
-        log.info(msg%(len(dryRunIdx), memUsage))
+        log.info(msg%(len(dryRunIdx), memUsage))                        # type: ignore
 
         # Return specially formatted string
         mem_per_worker = "estimate_memuse:" + str(max(1, int(np.ceil(memUsage))))
         log.debug("Finished memory estimation, returning `mem_per_worker = %s`", mem_per_worker)
         return mem_per_worker
 
-    def compute(self, debug=False):
+    def compute(
+            self,
+            debug: bool = False) -> Union[List, None]:
         """
         Perform the actual parallel execution of `func`
 
@@ -779,7 +795,7 @@ def compute(self, debug=False):
         # If `prepare_client` has not been called yet, don't attempt to compute anything
         if self.client is None:
             log.debug("No parallel computing client allocated, exiting")
-            return
+            return None
 
         # Ensure `debug` is a simple Boolean flag
         if not isinstance(debug, bool):
@@ -849,7 +865,7 @@ def init_acme(dask_worker, syspath):
         if isinstance(self.client.cluster, dj.SLURMCluster):
             logFiles = self.client.cluster.job_header.split("--output=")[1].replace("%j", "{}")
             logDir = os.path.split(logFiles)[0]
-        else:
+        else:                                                           # pragma: no cover
             logFiles = []
             logDir = os.path.dirname(self.client.cluster.dashboard_link) + "/info/main/workers.html"
         msg = "Preparing %d parallel calls of `%s` using %d workers"
@@ -916,7 +932,7 @@ def init_acme(dask_worker, syspath):
                     msg += "Please check SLURM logs in %s"%(logDir)
 
             # In case of a `LocalCluster`, syphon worker logs
-            else:
+            else:                                                       # pragma: no cover
                 msg += "Parallel worker log details: \n"
                 workerLogs = self.client.get_worker_logs().values()
                 for wLog in workerLogs:
@@ -932,7 +948,9 @@ def init_acme(dask_worker, syspath):
         # Either return collected by-worker results or the filepaths of results
         return values
 
-    def post_process(self, futures):
+    def post_process(
+            self,
+            futures: dd.Future) -> Union[List, None]:
         """
         Local helper to post-process results on disk/in-memory
 
@@ -1023,7 +1041,7 @@ def post_process(self, futures):
                     # would contain invalid file-links and move compute results out
                     # of payload dir
                     if picklesFound:
-                        os.unlink(self.results_container)
+                        os.unlink(self.results_container)               # type: ignore
                         wrng = "Some compute runs could not be saved as HDF5, " +\
                             "collection container %s has been removed as it would " +\
                                 "comprise invalid file-links"
@@ -1077,20 +1095,20 @@ def post_process(self, futures):
 
         # Print final triumphant output message and force-flush all logging handlers
         if len(successMsg) > 0:
-            log.announce(successMsg)
+            log.announce(successMsg)                                    # type: ignore
         log.info(finalMsg)
         for h in log.handlers:
             if hasattr(h, "flush"):
                 h.flush()
         return values
 
-    def cleanup(self):
+    def cleanup(self) -> None:
         """
         Shut down any ad-hoc distributed computing clients created by `prepare_client`
         """
 
         # If `prepare_client` has not been launched yet, just get outta here
-        if not hasattr(self, "client"):
+        if not hasattr(self, "client"):                                 # pragma: no cover
             log.debug("Helper `prepare_client` not yet launched, exiting")
             return
         if self.stop_client and self.client is not None:
@@ -1103,7 +1121,7 @@ def cleanup(self):
         return
 
     @staticmethod
-    def func_wrapper(*args, **kwargs):
+    def func_wrapper(*args: Any, **kwargs: Optional[Any]) -> None:      # pragma: no cover
         """
         If the output of `func` is saved to disk, wrap `func` with this static
         method to take care of filling up HDF5/pickle files
@@ -1124,23 +1142,23 @@ def func_wrapper(*args, **kwargs):
 
         # Set up logger
         log = logging.getLogger(logName)
-        log.setLevel(logLevel)
+        log.setLevel(logLevel)                                          # type: ignore
         for h in log.handlers:
-            h.setLevel(logLevel)
+            h.setLevel(logLevel)                                        # type: ignore
 
         # Call user-provided function
-        result = func(*args, **kwargs)
+        result = func(*args, **kwargs)                                  # type: ignore
 
         # For memory estimation runs, don't start saving stuff
         if memEstRun:
             return
 
         # Save results: either (try to) use HDF5 or pickle stuff
-        if fname.endswith(".h5"):
+        if fname.endswith(".h5"):                                       # type: ignore
 
             grpName = ""
             if singleFile:
-                lock = dd.lock.Lock(name=os.path.basename(fname))
+                lock = dd.lock.Lock(name=os.path.basename(fname))       # type: ignore
                 lock.acquire()
                 grpName = "comp_{}/".format(taskID)
 
@@ -1160,8 +1178,8 @@ def func_wrapper(*args, **kwargs):
                             log.debug("Created new dataset `result_0` in %s", fname)
                     else:
                         if singleFile:
-                            idx = [slice(None)] * len(h5f["result_0"].shape)
-                            idx[stackingDim] = taskID
+                            idx = [slice(None)] * len(h5f["result_0"].shape)    # type: ignore
+                            idx[stackingDim] = taskID                           # type: ignore
                             h5f["result_0"][tuple(idx)] = result[0]
                             log.debug("Wrote to pre-allocated dataset `result_0` in %s", fname)
                             for rk, res in enumerate(result[1:]):
@@ -1181,8 +1199,8 @@ def func_wrapper(*args, **kwargs):
                     or "One of data, shape or dtype must be specified" in str(exc)) \
                         and not singleFile:
                     try:
-                        os.unlink(fname)
-                        pname = fname.rstrip(".h5") + ".pickle"
+                        os.unlink(fname)                                # type: ignore
+                        pname = fname.rstrip(".h5") + ".pickle"         # type: ignore
                         with open(os.path.join(pname), "wb") as pkf:
                             pickle.dump(result, pkf)
                         msg = "Could not write %s results have been pickled instead: %s. Return values are most likely " +\
@@ -1209,10 +1227,12 @@ def func_wrapper(*args, **kwargs):
         else:
 
             try:
-                with open(os.path.join(fname), "wb") as pkf:
+                with open(os.path.join(fname), "wb") as pkf:            # type: ignore
                     pickle.dump(result, pkf)
                     log.debug("Pickled to %s", fname)
             except pickle.PicklingError as pexc:
                 err = "Could not pickle results to file %s. Original error message: %s"
                 log.error(err, fname, str(pexc))
                 raise pexc
+
+        return
diff --git a/acme/dask_helpers.py b/acme/dask_helpers.py
index 98b16e6..4f96c3e 100644
--- a/acme/dask_helpers.py
+++ b/acme/dask_helpers.py
@@ -11,6 +11,7 @@
 import os
 import sys
 import socket
+import platform
 import subprocess
 import getpass
 import time
@@ -18,7 +19,7 @@
 import textwrap
 import numpy as np
 from tqdm import tqdm
-if sys.platform == "win32":
+if sys.platform == "win32":                                             # pragma: no cover
     # tqdm breaks term colors on Windows - fix that (tqdm issue #446)
     import colorama
     colorama.deinit()
@@ -26,26 +27,29 @@
 from dask_jobqueue import SLURMCluster
 from dask.distributed import Client, get_client, LocalCluster
 from datetime import datetime, timedelta
+from typing import List, Optional, Any, Union
 
 # Local imports
 from acme import __deprecated__, __deprecation_wrng__
-from .shared import user_input, user_yesno
+from .shared import user_input, user_yesno, is_jupyter
 from .spy_interface import scalar_parser, log
 
-__all__ = ["esi_cluster_setup", "local_cluster_setup", "cluster_cleanup", "slurm_cluster_setup"]
+__all__: List["str"] = ["esi_cluster_setup", "local_cluster_setup", "cluster_cleanup", "slurm_cluster_setup"]
 
 
 # Setup SLURM workers on the ESI HPC cluster
-def esi_cluster_setup(partition="8GBXS",
-                      n_workers=2,
-                      mem_per_worker="auto",
-                      n_workers_startup=1,
-                      timeout=60,
-                      interactive=True,
-                      interactive_wait=120,
-                      start_client=True,
-                      job_extra=[],
-                      **kwargs):
+def esi_cluster_setup(
+        partition: str,
+        n_workers: int = 2,
+        mem_per_worker: str = "auto",
+        cores_per_worker: Optional[int] = None,
+        n_workers_startup: int = 1,
+        timeout: int = 60,
+        interactive: bool = True,
+        interactive_wait: int = 120,
+        start_client: bool = True,
+        job_extra: List = [],
+        **kwargs: Optional[Any]) -> Union[Client, SLURMCluster, LocalCluster]:
     """
     Start a Dask distributed SLURM worker cluster on the ESI HPC infrastructure
     (or local multi-processing)
@@ -58,18 +62,23 @@ def esi_cluster_setup(partition="8GBXS",
         cluster.
     n_workers : int
         Number of SLURM workers (=jobs) to spawn
-    mem_per_worker : None or str
+    mem_per_worker : str
         Memory booking for each worker. Can be specified either in megabytes
         (e.g., ``mem_per_worker = 1500MB``) or gigabytes (e.g., ``mem_per_worker = "2GB"``).
-        If `mem_per_worker` is `None`, or `"auto"` it is attempted to infer a sane default value
+        If `mem_per_worker` is `"auto"` it is attempted to infer a sane default value
         from the chosen partition, e.g., for ``partition = "8GBS"`` `mem_per_worker` is
-        automatically set to the allowed maximum of `'8GB'`. However, even in
-        queues with guaranteed memory bookings, it is possible to allocate less
+        automatically set to the allowed maximum of `'8GB'`. On the IBM POWER
+        partition "E880", `mem_per_worker` is set to 16 GB if not provided.
+        Note, even in queues with guaranteed memory bookings, it is possible to allocate less
         memory than the allowed maximum per worker to spawn numerous low-memory
         workers. See Examples for details.
+    cores_per_worker : None or int
+        Number of CPU cores allocated for each worker. If `None`, core-count
+        is set based on partition settings (`DefMemPerCPU`) with respect to
+        CPU architecture (minimum 1 on x86_64, and 4 on IBM POWER).
     n_workers_startup : int
-        Number of spawned workers to wait for. If `n_workers_startup` is `100` (default),
-        the code does not proceed until either 100 SLURM jobs are running or the
+        Number of spawned workers to wait for. If `n_workers_startup` is `1` (default),
+        the code does not proceed until either 1 SLURM job is running or the
         `timeout` interval has been exceeded.
     timeout : int
         Number of seconds to wait for requested workers to start (see `n_workers_startup`).
@@ -107,6 +116,11 @@ def esi_cluster_setup(partition="8GBXS",
 
     >>> client = esi_cluster_setup(n_workers=10, partition="8GBS", mem_per_worker="2GB")
 
+    Use default settings to start 2 SLURM workers in the IBM POWER E880 partition
+    (allocating 4 cores and 16 GB memory per worker)
+
+    >>> client = esi_cluster_setup(partition="E880")
+
     The underlying distributed computing cluster can be accessed using
 
     >>> client.cluster
@@ -115,7 +129,7 @@ def esi_cluster_setup(partition="8GBXS",
     -----
     The employed parallel computing engine relies on the concurrent processing library
     `Dask <https://docs.dask.org/en/latest/>`_. Thus, the distributed computing
-    clients generated here are in fact instances of :class:`dask.distributed.Client`.
+    clients generated here are in fact instances of :class:`distributed.Client`.
     This function specifically acts  as a wrapper for :class:`dask_jobqueue.SLURMCluster`.
     Users familiar with Dask in general and its distributed scheduler and cluster
     objects in particular, may leverage Dask's entire API to fine-tune parallel
@@ -130,14 +144,14 @@ def esi_cluster_setup(partition="8GBXS",
     """
 
     # For later reference: dynamically fetch name of current function
-    funcName = "<{}>".format(inspect.currentframe().f_code.co_name)
+    funcName = "<{}>".format(inspect.currentframe().f_code.co_name)     # type: ignore
 
     # Backwards compatibility: legacy keywords are converted to new nomenclature
     if any(kw in kwargs for kw in __deprecated__):
         log.warning(__deprecation_wrng__)
-        n_workers = kwargs.pop("n_jobs", n_workers)
+        n_workers = kwargs.pop("n_jobs", n_workers)                             # type: ignore
         mem_per_worker = kwargs.pop("mem_per_job", mem_per_worker)
-        n_workers_startup = kwargs.pop("n_jobs_startup", n_workers_startup)
+        n_workers_startup = kwargs.pop("n_jobs_startup", n_workers_startup)     # type: ignore
         log.debug("Set `n_workers = n_jobs`, `mem_per_worker = mem_per_job`\
                   and `n_workers_startup = n_jobs_startup`")
 
@@ -189,33 +203,50 @@ def esi_cluster_setup(partition="8GBXS",
     processes_per_worker = kwargs.pop("processes_per_worker", 1)
     log.debug("Found `sinfo`, set `processes_per_worker` to %d", processes_per_worker)
 
+    # Get micro-architecture of submitting host
+    mArch = platform.machine()
+
     # If partition is "auto" use `mem_per_worker` to pick pseudo-optimal partition
     # Note: the `np.where` gymnastic below is necessary since `argmin` refuses
     # to return multiple matches; if `mem_per_worker` is 12, then ``memDiff = [4, 4, ...]``,
     # however, 8GB won't fit a 12GB worker, so we have to pick the second match 16GB
-    if isinstance(partition, str) and partition == "auto":
-        if not isinstance(mem_per_worker, str) or mem_per_worker.find("estimate_memuse:") < 0:
-            msg = "Cannot auto-select partition without first invoking memory estimation in `ParallelMap`. "
-            log.error(msg)
-            raise IOError("%s %s"%(funcName, msg))
-        memEstimate = int(mem_per_worker.replace("estimate_memuse:" ,""))
-        mem_per_worker = "auto"
-        log.info("Automatically selecting SLURM partition...")
-        availPartitions = _get_slurm_partitions()
-        gbQueues = np.unique([int(queue.split("GB")[0]) for queue in availPartitions if queue[0].isdigit()])
-        memDiff = np.abs(gbQueues - memEstimate)
-        queueIdx = np.where(memDiff == memDiff.min())[0][-1]
-        partition = "{}GBXS".format(gbQueues[queueIdx])
-        msg = "Picked partition %s based on estimated memory consumption of %d GB"
-        log.info(msg, partition, memEstimate)
-
-    # Extract by-worker CPU core count from anonymous keyword args or...
-    if kwargs.get("n_cores") is not None:
-        n_cores = kwargs.pop("n_cores")
-        log.debug("Set `n_cores = %d` from kwargs", n_cores)
-    else:
-        # ...get memory limit (*in MB*) of chosen partition and set core count
-        # accordingly (multiple of 8 wrt to GB RAM)
+    if isinstance(partition, str):
+        if partition == "auto":
+            if not isinstance(mem_per_worker, str) or mem_per_worker.find("estimate_memuse:") < 0:
+                msg = "Cannot auto-select partition without first invoking memory estimation in `ParallelMap`. "
+                log.error(msg)
+                raise IOError("%s %s"%(funcName, msg))
+            memEstimate = int(mem_per_worker.replace("estimate_memuse:", ""))
+            mem_per_worker = "auto"
+            log.info("Automatically selecting SLURM partition...")
+            availPartitions = _get_slurm_partitions()
+            if mArch == "x86_64":
+                gbQueues = np.unique([int(queue.split("GB")[0]) for queue in availPartitions if queue[0].isdigit()])
+                memDiff = np.abs(gbQueues - memEstimate)
+                queueIdx = np.where(memDiff == memDiff.min())[0][-1]
+                partition = "{}GBXS".format(gbQueues[queueIdx])
+            else:
+                partition = "E880"
+                mem_per_worker = f"{memEstimate} GB"
+            msg = "Picked partition %s based on estimated memory consumption of %d GB"
+            log.info(msg, partition, memEstimate)
+        else:
+            if (partition == "E880" and mArch == "x86_64") or \
+               (mArch == "ppc64le" and partition != "E880"):
+                otherArch = list(set(["x86_64", "ppc64le"]).difference([mArch]))[0]
+                msg = "Cannot start SLURM workers in partition %s with " +\
+                    "architecture %s from submitting host with architecture %s. " +\
+                    "Start x86_64 workers from esi-svhpc{1,2,3} and POWER workers from the hub."
+                raise ValueError(msg%(partition, otherArch, mArch))
+
+    # Convert "auto" memory selection query to `None` for easier handling below`
+    if isinstance(mem_per_worker, str) and mem_per_worker == "auto":
+        mem_per_worker = None                                       # type: ignore
+        log.debug("Using auto-memory selection")
+
+    # If not explicitly provided, extract by-worker CPU core count from
+    # partition via `DefMeMPerCPU` and `mem_per_worker`
+    if cores_per_worker is None:
         try:
             log.debug("Using `scontrol` to get partition info")
             pc = subprocess.run("scontrol -o show partition {}".format(partition),
@@ -226,8 +257,34 @@ def esi_cluster_setup(partition="8GBXS",
             msg = "Cannot fetch available memory per CPU in SLURM: %s"
             log.error(msg, str(exc))
             raise IOError("%s %s"%(funcName, msg%(str(exc))))
-        n_cores = int(defMem / 8000)
-        log.debug("Using `n_cores=%d`", n_cores)
+
+        # Use ESI-specific x86_64 partition layout (8GB(S/X/L), 16GB(S/X/L), ... )
+        # to get infer memory and core count; use "sane" (fat quotes)
+        # defaults on IBM POWER (if nothing was provided, use 4 cores/16 GB per worker)
+        if mArch == "x86_64":
+            memPerCore = 8000
+        else:
+            if mem_per_worker is not None:
+                try:
+                    memVal = float(mem_per_worker.replace("MB", "").replace("GB", ""))
+                except:
+                    raise ValueError("Invalid value of `mem_per_worker`: %s"%mem_per_worker)
+                if memVal <= 0:
+                    raise ValueError("Value of `mem_per_worker` has to be > 0!")
+                if "MB" in mem_per_worker:
+                    defMem = int(memVal)
+                else:
+                    defMem = int(round(memVal * 1000))
+                log.debug("Found `mem_per_worker` set to %d MB", defMem)
+            else:
+                defMem = 16000                  # default to 4 cores per worker if no mem req was specified
+                mem_per_worker = f"{defMem} MB"
+                log.debug("No `mem_per_worker` specified, using default of %d MB", defMem)
+            memPerCore = 4000
+
+        # Set core-count per worker (applies to both x86_64 and ppc64le)
+        cores_per_worker = max(1, int(defMem / memPerCore))
+        log.debug("Derived core-count from partition: `cores_per_worker=%d`", cores_per_worker)
 
     # Determine if `job_extra`` is a list (this is also checked in `slurm_cluster_setup`,
     # but we may need to append to it, so ensure that's possible)
@@ -250,25 +307,28 @@ def esi_cluster_setup(partition="8GBXS",
         log.debug("Setting `--output=%s`", out_files)
 
     # Let the SLURM-specific setup function do the rest (returns client or cluster)
-    return slurm_cluster_setup(partition, n_cores, n_workers, processes_per_worker, mem_per_worker,
-                               n_workers_startup, timeout, interactive, interactive_wait,
-                               start_client, job_extra, invalid_partitions=["DEV", "PPC"], **kwargs)
+    return slurm_cluster_setup(partition, cores_per_worker, n_workers,      # type: ignore
+                               processes_per_worker, mem_per_worker,
+                               n_workers_startup, timeout, interactive,
+                               interactive_wait, start_client, job_extra,
+                               invalid_partitions=["DEV", "ESI"], **kwargs)
 
 
 # Setup SLURM cluster
-def slurm_cluster_setup(partition="partition_name",
-                        n_cores=1,
-                        n_workers=1,
-                        processes_per_worker=1,
-                        mem_per_worker="1GB",
-                        n_workers_startup=1,
-                        timeout=60,
-                        interactive=True,
-                        interactive_wait=10,
-                        start_client=True,
-                        job_extra=[],
-                        invalid_partitions=[],
-                        **kwargs):
+def slurm_cluster_setup(
+        partition: str = "partition_name",
+        n_cores: int = 1,
+        n_workers: int = 1,
+        processes_per_worker: int = 1,
+        mem_per_worker: str = "1GB",
+        n_workers_startup: int = 1,
+        timeout: int = 60,
+        interactive: bool = True,
+        interactive_wait: int = 10,
+        start_client: bool = True,
+        job_extra: List = [],
+        invalid_partitions: List = [],
+        **kwargs: Optional[Any]) -> Union[Client, SLURMCluster, None]:
     """
     Start a distributed Dask cluster of parallel processing workers using SLURM
 
@@ -319,9 +379,11 @@ def slurm_cluster_setup(partition="partition_name",
 
     Returns
     -------
-    proc : object
+    proc : object or None
         A distributed computing client (if ``start_client = True``) or
-        a distributed computing cluster (otherwise).
+        a distributed computing cluster (otherwise). If no SLURM workers
+        can be started within the given timeout interval, `proc` is set
+        to `None`.
 
     See also
     --------
@@ -332,15 +394,15 @@ def slurm_cluster_setup(partition="partition_name",
     """
 
     # For later reference: dynamically fetch name of current function
-    funcName = "<{}>".format(inspect.currentframe().f_code.co_name)
+    funcName = "<{}>".format(inspect.currentframe().f_code.co_name)     # type: ignore
 
     # Backwards compatibility: legacy keywords are converted to new nomenclature
     if any(kw in kwargs for kw in __deprecated__):
         log.warning(__deprecation_wrng__)
-        n_workers = kwargs.pop("n_jobs", n_workers)
-        processes_per_worker = kwargs.pop("workers_per_job", processes_per_worker)
-        mem_per_worker = kwargs.pop("mem_per_job", mem_per_worker)
-        n_workers_startup = kwargs.pop("n_jobs_startup", n_workers_startup)
+        n_workers = kwargs.pop("n_jobs", n_workers)                                 # type: ignore
+        processes_per_worker = kwargs.pop("workers_per_job", processes_per_worker)  # type: ignore
+        mem_per_worker = kwargs.pop("mem_per_job", mem_per_worker)                  # type: ignore
+        n_workers_startup = kwargs.pop("n_jobs_startup", n_workers_startup)         # type: ignore
         log.debug("Set `n_workers = n_jobs`, `processes_per_worker = workers_per_job`, \
                   `mem_per_worker = mem_per_job` \
                   and `n_workers_startup = n_jobs_startup`")
@@ -368,7 +430,7 @@ def slurm_cluster_setup(partition="partition_name",
     # Get requested memory per worker
     if isinstance(mem_per_worker, str):
         if mem_per_worker == "auto":
-            mem_per_worker = None
+            mem_per_worker = None                                       # type: ignore
             log.debug("Using auto-memory selection")
     if mem_per_worker is not None:
         msg = "`mem_per_worker` has to be a valid memory specifier (e.g., '8GB', '12000MB'), not %s"
@@ -378,7 +440,7 @@ def slurm_cluster_setup(partition="partition_name",
         if not any(szstr in mem_per_worker for szstr in ["MB", "GB"]):
             log.error(msg, mem_per_worker)
             raise ValueError("%s %s"%(funcName, msg%(mem_per_worker)))
-        memNumeric = mem_per_worker.replace("MB","").replace("GB","")
+        memNumeric = mem_per_worker.replace("MB", "").replace("GB", "")
         log.debug("Found `mem_per_worker = %s` in input args", mem_per_worker)
         try:
             memVal = float(memNumeric)
@@ -404,21 +466,24 @@ def slurm_cluster_setup(partition="partition_name",
     try:
         mem_lim = int(pc.stdout.strip().partition("MaxMemPerCPU=")[-1].split()[0])
     except IndexError:
-        try:
-            mem_lim = int(pc.stdout.strip().partition("DefMemPerCPU=")[-1].split()[0])
-        except IndexError:
-            mem_lim = np.inf
+        mem_lim = np.inf                                            # type: ignore
     log.debug("Found a limit of  %s MB", str(mem_lim))
 
     # Consolidate requested memory with chosen partition (or assign default memory)
     if mem_per_worker is None:
-        mem_per_worker = str(mem_lim) + "MB"
+        if np.isinf(mem_lim):
+            try:
+                mem_per_worker = pc.stdout.strip().partition("DefMemPerCPU=")[-1].split()[0] + "MB"
+            except IndexError:
+                raise ValueError("Cannot infer any default memory setting from partition %s"%partition)
+        else:
+            mem_per_worker = str(mem_lim) + "MB"
         log.debug("Using partition limit of %s MB", str(mem_lim))
     else:
         if "MB" in mem_per_worker:
-            mem_req = int(mem_per_worker[:mem_per_worker.find("MB")])
+            mem_req = int(memVal)
         else:
-            mem_req = int(round(float(mem_per_worker[:mem_per_worker.find("GB")]) * 1000))
+            mem_req = int(round(memVal * 1000))
         if mem_req > mem_lim:
             msg = "`mem_per_worker` exceeds limit of %d MB for partition %s. " +\
                 "Capping memory at partition limit. "
@@ -531,11 +596,11 @@ def slurm_cluster_setup(partition="partition_name",
 
     # Fire up waiting routine to avoid returning an undercooked cluster
     if _cluster_waiter(cluster, funcName, n_workers, timeout, interactive, interactive_wait):
-        return
+        return None
 
     # Kill a zombie cluster in non-interactive mode
     if not interactive and count_online_workers(cluster) == 0:
-        cluster.close()
+        cluster_cleanup(Client(cluster))
         msg = "SLURM workers could not be started within given time-out " +\
               "interval of %d seconds"
         log.error(msg, timeout)
@@ -551,13 +616,13 @@ def slurm_cluster_setup(partition="partition_name",
     return cluster
 
 
-def _get_slurm_partitions():
+def _get_slurm_partitions() -> List:
     """
     Local helper to fetch all partitions defined in SLURM
     """
 
     # For later reference: dynamically fetch name of current function
-    funcName = "<{}>".format(inspect.currentframe().f_code.co_name)
+    funcName = "<{}>".format(inspect.currentframe().f_code.co_name)     # type: ignore
 
     # Retrieve all partitions currently available in SLURM
     log.debug("Use `sinfo` to fetch available partitions")
@@ -577,7 +642,13 @@ def _get_slurm_partitions():
     return out.split()
 
 
-def _cluster_waiter(cluster, funcName, total_workers, timeout, interactive, interactive_wait):
+def _cluster_waiter(
+        cluster: SLURMCluster,
+        funcName: str,
+        total_workers: int,
+        timeout: int,
+        interactive: bool,
+        interactive_wait: int) -> bool:
     """
     Local helper that can be called recursively
     """
@@ -598,7 +669,7 @@ def _cluster_waiter(cluster, funcName, total_workers, timeout, interactive, inte
     ani.close()
 
     # If we ran out of time before all workers could be started, ask what to do
-    if counter == timeout and interactive:
+    if counter == timeout and interactive:                              # pragma: no cover
         msg = "SLURM workers could not be started within given time-out " +\
               "interval of %d seconds"
         log.info(msg, timeout)
@@ -611,7 +682,7 @@ def _cluster_waiter(cluster, funcName, total_workers, timeout, interactive, inte
             return _cluster_waiter(cluster, funcName, total_workers, 60, True, 60)
         elif choice == "a":
             log.info("Closing cluster...")
-            cluster.close()
+            cluster_cleanup(Client(cluster))
             return True
         else:
             if wrkrs == 0:
@@ -623,16 +694,17 @@ def _cluster_waiter(cluster, funcName, total_workers, timeout, interactive, inte
                     _cluster_waiter(cluster, funcName, total_workers, 60, True, 60)
                 else:
                     log.info("Closing cluster...")
-                    cluster.close()
+                    cluster_cleanup(Client(cluster))
                     return True
 
     return False
 
 
-def local_cluster_setup(n_workers=None,
-                        mem_per_worker=None,
-                        interactive=True,
-                        start_client=True):
+def local_cluster_setup(
+        n_workers: Optional[int] = None,
+        mem_per_worker: Optional[str] = None,
+        interactive: bool = True,
+        start_client: bool = True) -> Union[Client, LocalCluster, None]:
     """
     Start a local distributed Dask multi-processing cluster
 
@@ -649,6 +721,7 @@ def local_cluster_setup(n_workers=None,
         of calls to `local_cluster_setup` inside a script's main module block.
         See Notes for details. If `interactive` is `False`, the dialog is not shown.
     start_client : bool
+        DEPRECATED. Will be removed in next release.
         If `True`, a distributed computing client is launched and attached to
         the workers. If `start_client` is `False`, only a distributed
         computing cluster is started to which compute-clients can connect.
@@ -657,7 +730,8 @@ def local_cluster_setup(n_workers=None,
     -------
     proc : object
         A distributed computing client (if ``start_client = True``) or
-        a distributed computing cluster (otherwise).
+        a distributed computing cluster (otherwise). If a client cannot
+        be started, `proc` is set to `None`.
 
     Notes
     -----
@@ -707,7 +781,7 @@ def local_cluster_setup(n_workers=None,
     """
 
     # For later reference: dynamically fetch name of current function
-    funcName = "<{}>".format(inspect.currentframe().f_code.co_name)
+    funcName = "<{}>".format(inspect.currentframe().f_code.co_name)     # type: ignore
 
     # Determine if cluster allocation is happening interactively
     if not isinstance(interactive, bool):
@@ -717,27 +791,12 @@ def local_cluster_setup(n_workers=None,
     log.debug("Using `interactive = %s`", str(interactive))
 
     # Determine if a dask client was requested
-    if not isinstance(start_client, bool):
-        msg = "`start_client` has to be Boolean, not $s"
-        log.error(msg, str(type(start_client)))
-        raise TypeError("%s %s"%(funcName, msg%(str(type(start_client)))))
-    log.debug("Using `start_client = %s`", str(start_client))
-
-    # Check, if we're running inside a Jupyter notebook...
-    try:
-        ipy = get_ipython()
-        if ipy.__class__.__name__ == "ZMQInteractiveShell":
-            maybeScript = False # Jupyter Notebook
-            log.debug("Running in a Jupyter Notebook")
-        else:
-            maybeScript = True  # iPython shell
-            log.debug("Running in an iPython shell")
-    except NameError:
-        maybeScript = True      # Python shell
-        log.debug("Running in a standard Python shell")
+    if not isinstance(start_client, bool) or start_client is False:
+        log.warning("The keyword `start_client` in `local_cluster_setup` is DEPRECATED and will be ignored.")
+        start_client = True
 
     # ...if not, print warning/info message
-    if maybeScript:
+    if not is_jupyter():
         msg = """\
         If you use a script to start a local parallel computing client, please ensure
         the call to `local_cluster_setup` is wrapped inside a main module block, i.e.,
@@ -756,11 +815,11 @@ def local_cluster_setup(n_workers=None,
 
     # Additional safe-guard: if a script is executed, double-check with the user
     # for proper main idiom usage
-    if interactive:
+    if interactive:                                                     # pragma: no cover
         msg = "{name:s} If launched from a script, did you wrap your code " +\
             "inside a __main__ module block?"
         if not user_yesno(msg.format(name=funcName), default="no"):
-            return
+            return None
 
     # Start the actual distributed client
     if n_workers is not None or mem_per_worker is not None:
@@ -777,16 +836,15 @@ def local_cluster_setup(n_workers=None,
     return client.cluster
 
 
-def cluster_cleanup(client=None):
+def cluster_cleanup(client: Optional[Client] = None) -> None:
     """
     Stop and close dangling parallel processing workers
 
     Parameters
     ----------
     client : dask distributed computing client or None
-        Either a concrete `dask client object <https://distributed.dask.org/en/latest/client.html>`_
-        or `None`. If `None`, a global client is queried for and shut-down
-        if found (without confirmation!).
+        Either a  :class:`distributed.Client` or `None`. If `None`, a
+        global client is queried for and shut-down if found (without confirmation!).
 
     Returns
     -------
@@ -800,7 +858,7 @@ def cluster_cleanup(client=None):
     """
 
     # For later reference: dynamically fetch name of current function
-    funcName = "<{}>".format(inspect.currentframe().f_code.co_name)
+    funcName = "<{}>".format(inspect.currentframe().f_code.co_name)     # type: ignore
 
     # Attempt to establish connection to dask client
     if client is None:
@@ -809,7 +867,7 @@ def cluster_cleanup(client=None):
         except ValueError:
             log.warning("No dangling clients or clusters found.")
             return
-        except Exception as exc:
+        except Exception as exc:                                        # pragma: no cover
             log.error("Error looking for dask client")
             raise exc
     else:
@@ -829,9 +887,13 @@ def cluster_cleanup(client=None):
         userClust = "cluster {0}_{1}".format(userName, jobID)
     nWorkers = count_online_workers(client.cluster)
 
-    # If connection was successful, first close the client, then the cluster
+    # First gracefully shut down all workers, then close client
+    client.retire_workers(list(client.scheduler_info()['workers']), close_workers=True)
     client.close()
-    client.cluster.close()
+    try:
+        client.cluster.close()
+    except Exception as exc:
+        log.warning("Could not gracefully shut down cluster: %s", str(exc))
 
     # Communicate what just happened and get outta here
     msg = "Successfully shut down %s containing %d workers"
@@ -840,7 +902,7 @@ def cluster_cleanup(client=None):
     return
 
 
-def count_online_workers(cluster):
+def count_online_workers(cluster: SLURMCluster) -> int:
     """
     Local replacement for the late `._count_active_workers` class method
     """
diff --git a/acme/frontend.py b/acme/frontend.py
index 8ee4a63..2611e98 100644
--- a/acme/frontend.py
+++ b/acme/frontend.py
@@ -11,6 +11,7 @@
 import inspect
 import numpy as np
 import logging
+from typing import Any, Callable, Optional, Union, List
 
 # Local imports
 from acme import __deprecated__, __deprecation_wrng__, __version__
@@ -19,7 +20,7 @@
 from .shared import _scalar_parser, sizeOf
 from . import shared as acs
 
-__all__ = ["ParallelMap"]
+__all__: List["str"] = ["ParallelMap"]
 
 # Fetch logger
 log = logging.getLogger("ACME")
@@ -37,25 +38,25 @@ class ParallelMap(object):
 
     def __init__(
         self,
-        func,
-        *args,
-        n_inputs="auto",
-        write_worker_results=True,
-        output_dir=None,
-        result_shape=None,
-        result_dtype="float",
-        single_file=False,
-        write_pickle=False,
-        partition="auto",
-        n_workers="auto",
-        mem_per_worker="auto",
-        setup_timeout=60,
-        setup_interactive=True,
-        stop_client="auto",
-        verbose=None,
-        dryrun=False,
-        logfile=None,
-        **kwargs):
+        func: Callable,
+        *args: Any,
+        n_inputs: Union[int, str] = "auto",
+        write_worker_results: bool = True,
+        output_dir: Optional[str] = None,
+        result_shape: Optional[tuple[Optional[int], ...]] = None,
+        result_dtype: str = "float",
+        single_file: bool = False,
+        write_pickle: bool = False,
+        partition: str = "auto",
+        n_workers: Union[int, str] = "auto",
+        mem_per_worker: str = "auto",
+        setup_timeout: int = 60,
+        setup_interactive: bool = True,
+        stop_client: Union[bool, str] = "auto",
+        verbose: Optional[bool] = None,
+        dryrun: bool = False,
+        logfile: Optional[Union[bool, str]] = None,
+        **kwargs: Optional[Any]) -> None:
         """
         Context manager that executes user-defined functions in parallel
 
@@ -103,8 +104,8 @@ def __init__(
             resulting in a ``(n_inputs, 100)`` dataset or array. See Notes
             and Examples for details. See Examples as well as [1]_ and [2]_
             for more information.
-        result_dtype : str or None
-            Only relevant if `result_shape` is not `None`. If provided, determines
+        result_dtype : str
+            Only relevant if `result_shape` is not `None`. Determines
             the numerical datatype of the dataset laid out by `result_shape`.
             By default, results are stored in `float64` format. See [2]_ for
             more details.
@@ -239,13 +240,13 @@ def __init__(
 
         # First and foremost, set up logging system - logfile is processed later
         prepare_log(logname="ACME", verbose=verbose)
-        log.announce("This is ACME v. %s", __version__)
+        log.announce("This is ACME v. %s", __version__)                 # type: ignore
 
         # Backwards compatibility: legacy keywords are converted to new nomenclature
         if any(kw in kwargs for kw in __deprecated__):
             log.warning(__deprecation_wrng__)
-            n_workers = kwargs.pop("n_jobs", n_workers)
-            mem_per_worker = kwargs.pop("mem_per_job", mem_per_worker)
+            n_workers = kwargs.pop("n_jobs", n_workers)                 # type: ignore
+            mem_per_worker = kwargs.pop("mem_per_job", mem_per_worker)  # type: ignore
             log.debug("Set `n_workers = n_jobs` and \
                        mem_per_worker = mem_per_job`")
 
@@ -272,7 +273,12 @@ def __init__(
                                  verbose=verbose,
                                  logfile=logfile)
 
-    def prepare_input(self, func, n_inputs, *args, **kwargs):
+    def prepare_input(
+            self,
+            func: Callable,
+            n_inputs: Union[int, str],
+            *args: Any,
+            **kwargs: Optional[Any]) -> None:
         """
         User input parser
 
@@ -446,33 +452,40 @@ def prepare_input(self, func, n_inputs, *args, **kwargs):
         # Get out
         return
 
-    def compute(self):
+    def compute(self) -> None:
         """
         Shortcut to launch parallel computation via `ACMEdaemon`
         """
         log.debug("Invoking `compute` method")
         if hasattr(self, "daemon"):
             self.daemon.compute()
+        return
 
-    def cleanup(self):
+    def cleanup(self) -> None:
         """
         Shortcut to corresponding cleanup-routine provided by `ACMEdaemon`
         """
         log.debug("Invoking `cleanup` method")
         if hasattr(self, "daemon"):
             self.daemon.cleanup()
+        return
 
-    def __enter__(self):
+    def __enter__(self) -> ACMEdaemon:
         """
         If `ParallelMap` is used as context manager, launch `ACMEdaemon`
         """
         log.debug("Entering `ACMEdaemon` context")
         return self.daemon
 
-    def __exit__(self, exception_type, exception_value, exception_traceback):
+    def __exit__(
+            self,
+            exception_type: Any,
+            exception_value: Any,
+            exception_traceback: Any) -> None:
         """
         If `ParallelMap` is used as context manager, close any ad-hoc computing
         clients created by `ACMEdaemon`
         """
         log.debug("Exiting `ACMEdaemon` context")
         self.daemon.cleanup()
+        return
diff --git a/acme/logger.py b/acme/logger.py
index dbfbde6..638ff69 100644
--- a/acme/logger.py
+++ b/acme/logger.py
@@ -13,11 +13,15 @@
 import logging
 import os
 from logging import handlers
+from typing import Optional, Any, List
 
-__all__ = []
+__all__: List[str] = []
 
 
-def prepare_log(logname="ACME", logfile=None, verbose=None):
+def prepare_log(
+        logname: str = "ACME",
+        logfile: Optional[str] = None,
+        verbose: Optional[bool] = None) -> None:
     """
     Convenience function to set up ACME logger
 
@@ -41,7 +45,7 @@ def prepare_log(logname="ACME", logfile=None, verbose=None):
     """
 
     # For later reference: dynamically fetch name of current function
-    funcName = "<{}>".format(inspect.currentframe().f_code.co_name)
+    funcName = "<{}>".format(inspect.currentframe().f_code.co_name)     # type: ignore
 
     # Ensure `logname` can be processed
     if not isinstance(logname, str):
@@ -56,15 +60,18 @@ def prepare_log(logname="ACME", logfile=None, verbose=None):
     # Either parse provided `logfile` or set up an auto-generated file
     if logfile is not None and os.path.isfile(logfile):
         msg = "%s log-file %s already exists, appending to it"
-        warnings.showwarning(msg%(logname, logfile), RuntimeWarning,
-                             __file__, inspect.currentframe().f_lineno)
+        warnings.warn(msg%(logname, logfile))
 
     # Add custom "announce" level to logger
     announceLvl = logging.INFO + 5
     logging.addLevelName(announceLvl, "ANNOUNCE")
-    logging.ANNOUNCE = announceLvl
+    logging.ANNOUNCE = announceLvl                                      # type: ignore
 
-    def announce(self, msg, *args, **kwargs):
+    def announce(
+            self,
+            msg: str,
+            *args: Any,
+            **kwargs: Optional[Any]) -> None:
         """
         Log 'msg % args' with severity 'ANNOUNCE'.
 
@@ -76,8 +83,8 @@ def announce(self, msg, *args, **kwargs):
         if self.isEnabledFor(announceLvl):
             self._log(announceLvl, msg, args, **kwargs)
 
-    logging.getLoggerClass().announce = announce
-    logging.announce = announce
+    logging.getLoggerClass().announce = announce                        # type: ignore
+    logging.announce = announce                                         # type: ignore
 
     # Fetch/set up ACME logger
     log = logging.getLogger(logname)
@@ -130,7 +137,10 @@ class AcmeFormatter(logging.Formatter):
     Adapted from https://alexandra-zaharia.github.io/posts/make-your-own-custom-color-formatter-with-python-logging/
     """
 
-    def __init__(self, fmt, color=True):
+    def __init__(
+            self,
+            fmt: str,
+            color: bool = True):
 
         super().__init__()
 
@@ -152,7 +162,7 @@ def __init__(self, fmt, color=True):
             reset = ""
 
         fmtName = fmt.partition("%(name)s")
-        fmtName = fmtName[0] + bold + fmtName[1] + reset + fmtName[2]
+        fmtName = fmtName[0] + bold + fmtName[1] + reset + fmtName[2]   # type: ignore
         fmt = "".join(fmtName)
 
         fmtLvl = fmt.partition("%(levelname)s")
@@ -173,13 +183,15 @@ def __init__(self, fmt, color=True):
         self.FORMATS = {
             logging.DEBUG: "".join(fmtDebug),
             logging.INFO: "".join(fmtInfo),
-            logging.ANNOUNCE: "".join(fmtAnnounce),
+            logging.ANNOUNCE: "".join(fmtAnnounce),                     # type: ignore
             logging.WARNING: "".join(fmtWarn),
             logging.ERROR: "".join(fmtError),
             logging.CRITICAL: "".join(fmtError),
         }
 
-    def format(self, record):
+    def format(
+            self,
+            record: logging.LogRecord):
         logFmt = self.FORMATS.get(record.levelno)
         formatter = logging.Formatter(logFmt)
         return formatter.format(record)
diff --git a/acme/shared.py b/acme/shared.py
index 21e2ccb..bdf94b8 100644
--- a/acme/shared.py
+++ b/acme/shared.py
@@ -11,16 +11,16 @@
 import os
 import sys
 import socket
+import platform
+import select
 import subprocess
 import inspect
 import logging
 import traceback
-import multiprocessing
-import time
 import numpy as np
 import dask.distributed as dd
-from tqdm import tqdm
 from logging import handlers
+from typing import Any, Optional, List
 
 # Local imports
 from acme import __version__
@@ -29,10 +29,12 @@
 callCount = 0
 callMax = 1000000
 
-__all__ = []
+__all__: List["str"] = []
 
 
-def sizeOf(obj, varname):
+def sizeOf(
+        obj: Any,
+        varname: str) -> float:
     """
     Estimate memory consumption of Python objects
 
@@ -40,6 +42,8 @@ def sizeOf(obj, varname):
     ----------
     obj : Python object
         Any valid Python object whose memory footprint is of interest.
+    varname : str
+        Assigned name of `obj`
 
     Returns
     -------
@@ -58,7 +62,7 @@ def sizeOf(obj, varname):
     global callCount
 
     # For later reference: dynamically fetch name of current function
-    funcName = "<{}>".format(inspect.currentframe().f_code.co_name)
+    funcName = "<{}>".format(inspect.currentframe().f_code.co_name)     # type: ignore
 
     # Protect against circular object references
     callCount += 1
@@ -75,7 +79,7 @@ def sizeOf(obj, varname):
     return objsize
 
 
-def is_slurm_node():
+def is_slurm_node() -> bool:
     """
     Returns `True` if code is running on a SLURM-managed cluster node, `False`
     otherwise
@@ -92,7 +96,7 @@ def is_slurm_node():
     return len(out) > 0
 
 
-def is_esi_node():
+def is_esi_node() -> bool:
     """
     Returns `True` if code is running on an ESI cluster node, `False` otherwise
     """
@@ -103,13 +107,27 @@ def is_esi_node():
     return socket.gethostname().startswith("esi-sv") and os.path.isdir("/cs")
 
 
-def _scalar_parser(var, varname="varname", ntype="int_like", lims=[-np.inf, np.inf]):
+def is_x86_node() -> bool:
+    """
+    Returns `True` if code is running on an x86_64 node, `False` otherwise
+    """
+
+    # Fetch ACME logger and write debug message
+    log = logging.getLogger("ACME")
+    log.debug("Test if host is x86_64 micro-architecture")
+    return platform.machine() == "x86_64"
+
+def _scalar_parser(
+        var: Any,
+        varname: str = "varname",
+        ntype: str = "int_like",
+        lims: List = [-np.inf, np.inf]) -> None:
     """
     ACME-specific version of Syncopy's `scalar_parser` (used for cross-compatibility)
     """
 
     # Get name of calling method/function
-    funcName = "<{}>".format(inspect.currentframe().f_back.f_code.co_name)
+    funcName = "<{}>".format(inspect.currentframe().f_back.f_code.co_name)      # type: ignore
 
     # Make sure `var` is a scalar-like number
     msg = "%s `%s` has to be %s between %s and %s, not %s"
@@ -137,7 +155,9 @@ def _scalar_parser(var, varname="varname", ntype="int_like", lims=[-np.inf, np.i
     return
 
 
-def user_yesno(msg, default=None):
+def user_yesno(                                                         # pragma: no cover
+        msg: str,
+        default: Optional[str] = None) -> bool:
     """
     ACME specific version of user-input query
     """
@@ -162,78 +182,64 @@ def user_yesno(msg, default=None):
             print("Please respond with 'yes' or 'no' (or 'y' or 'n').\n")
 
 
-def user_input(msg, valid, default=None, timeout=None):
+def user_input(                                                         # pragma: no cover
+        msg: str,
+        valid: Optional[List] = None,
+        default: Optional[str] = None,
+        timeout: Optional[float] = None) -> str:
     """
     ACME specific version of user-input query
     """
 
+    # Prepare to log any uncaught exceptions
+    log = logging.getLogger("ACME")
+
     # Add trailing whitespace to `msg` if not already present and append
     # default reply (if provided)
     suffix = "" + " " * (not msg.endswith(" "))
     if default is not None:
         default = default.replace("[", "").replace("]","")
-        assert default in valid
-        suffix = "[Default: '{}'] ".format(default)
+        if valid is not None:
+            assert default in valid
+        suffix = f"[Default: '{default}'] "
     query = msg + suffix
 
-    if timeout is None:
-        return _get_user_input(query, valid, default)
-    else:
-        procQueue = multiprocessing.Queue()
-        proc = multiprocessing.Process(target=_queuing_input,
-                                       args=(procQueue,
-                                             sys.stdin.fileno(),
-                                             query,
-                                             valid,
-                                             default)
-                                       )
-        proc.start()
-        countdown = tqdm(desc="Time remaining", leave=True, bar_format="{desc}: {n}  ",
-                         initial=timeout, position=1)
-        ticker = 0
-        while procQueue.empty() and ticker < timeout:
-            time.sleep(1)
-            ticker += 1
-            countdown.n = timeout - ticker
-            countdown.refresh()   # force refresh to display elapsed time every second
-        countdown.close()
-        proc.terminate()
-
-        if not procQueue.empty():
-            choice = procQueue.get()
-        else:
-            choice = default
-        return choice
-
+    # Jupyter only supports hard-blocking `input` fields
+    if is_jupyter():
+        log.debug("Running inside Jupyter notebook, deactivating timeout")
+        timeout = None
 
-def _get_user_input(query, valid, default):
-    """
-    Performs the actual input query
-    """
-
-    # Wait for valid user input and return choice upon receipt
+    # Wait for user I/O
+    print(query)
     while True:
-        choice = input(query)
+        if timeout is None:
+            choice = input()
+        else:
+            stdin, _, _ = select.select([sys.stdin], [], [], timeout)
+            if stdin:
+                choice = sys.stdin.readline().strip()
+            else:
+                if default is None:
+                    err = f"No response received within the given timeout of {timeout} seconds. "
+                    raise TimeoutError(err)
         if default is not None and choice == "":
             return default
-        elif choice in valid:
-            return choice
+        elif valid is not None and choice not in valid:
+            print("Please respond with " + " or ".join(valid))
         else:
-            print("Please respond with '" + \
-                "or '".join(opt + "' " for opt in valid) + "\n")
-
-
-def _queuing_input(procQueue, stdin_fd, query, valid, default):
-    """
-    Target routine to tie subprocess to (in case input-query is time-restricted)
-    """
-    sys.stdin = os.fdopen(stdin_fd)
-    procQueue.put(_get_user_input(query, valid, default))
+            return choice
 
 
+def is_jupyter() -> bool:
+    try:
+        return get_ipython().__class__.__name__ == "ZMQInteractiveShell"    # type: ignore
+    except NameError:
+        return False
 
 
-def ctrlc_catcher(*excargs, **exckwargs):
+def ctrlc_catcher(
+        *excargs: Any,
+        **exckwargs: Optional[Any]) -> None:
     """
     Custom Traceback for properly handling CTRL + C interrupts while parallel
     computations are running
@@ -248,7 +254,7 @@ def ctrlc_catcher(*excargs, **exckwargs):
         shell, = excargs
         etype, evalue, etb = sys.exc_info()
         try:                            # careful: if iPython is used to launch a script, ``get_ipython`` is not defined
-            get_ipython()
+            get_ipython()               # type: ignore
             isipy = True
             sys.last_traceback = etb    # smartify ``sys``
         except NameError:
@@ -272,7 +278,7 @@ def ctrlc_catcher(*excargs, **exckwargs):
             log.debug("CTRL + C acknowledged, client and workers successfully killed")
 
     # Relay exception handling back to appropriate system tools
-    if isipy:
+    if isipy:                                                           # pragma: no cover
         shell.ipyTBshower(shell, exc_tuple=(etype, evalue, etb), **exckwargs)
     else:
         sys.__excepthook__(etype, evalue, etb)
@@ -281,9 +287,9 @@ def ctrlc_catcher(*excargs, **exckwargs):
     # printing was handled above)
     log.error("Exception received.")
     memHandler = [h for h in log.handlers if isinstance(h, handlers.MemoryHandler)][0]
-    if memHandler.target is not None:
+    if memHandler.target is not None:                                   # pragma: no cover
         memHandler.acquire()
-        with open(memHandler.target.baseFilename, "a", encoding="utf-8") as logfile:
+        with open(memHandler.target.baseFilename, "a", encoding="utf-8") as logfile:    # type: ignore
             logfile.write("".join(traceback.format_exception_only(etype, evalue)))
             logfile.write("".join(traceback.format_tb(etb)))
         memHandler.release()
diff --git a/acme/spy_interface.py b/acme/spy_interface.py
index 29d5df0..33efe49 100644
--- a/acme/spy_interface.py
+++ b/acme/spy_interface.py
@@ -14,8 +14,8 @@
 # Differentiate b/w being imported as Syncopy sub-package or standalone
 # ACME module: do not attempt to import syncopy but instead see if it has
 # already been imported; then ACME should use Syncopy's built-in logging
-if "syncopy" in sys.modules:
-    import syncopy as spy
+if "syncopy" in sys.modules:                                            # pragma: no cover
+    import syncopy as spy                                               # type: ignore
     log = logging.getLogger("syncopy")
     scalar_parser = lambda var, varname="", ntype=None, lims=None : \
         spy.shared.parsers.scalar_parser(var, varname=varname, ntype=ntype, lims=lims)
diff --git a/doc/source/images/classifier_tutorial.png b/doc/source/_static/classifier_tutorial.png
similarity index 100%
rename from doc/source/images/classifier_tutorial.png
rename to doc/source/_static/classifier_tutorial.png
diff --git a/doc/source/_static/classifier_tutorial.png.license b/doc/source/_static/classifier_tutorial.png.license
new file mode 100644
index 0000000..af5efa4
--- /dev/null
+++ b/doc/source/_static/classifier_tutorial.png.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2023 Ernst Strüngmann Institute (ESI) for Neuroscience in Cooperation with Max Planck Society
+
+SPDX-License-Identifier: CC-BY-NC-SA-1.0
diff --git a/doc/source/_static/subject0.png b/doc/source/_static/subject0.png
new file mode 100644
index 0000000..a1824c9
Binary files /dev/null and b/doc/source/_static/subject0.png differ
diff --git a/doc/source/_static/subject0.png.license b/doc/source/_static/subject0.png.license
new file mode 100644
index 0000000..af5efa4
--- /dev/null
+++ b/doc/source/_static/subject0.png.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2023 Ernst Strüngmann Institute (ESI) for Neuroscience in Cooperation with Max Planck Society
+
+SPDX-License-Identifier: CC-BY-NC-SA-1.0
diff --git a/doc/source/classifier_tutorial.rst b/doc/source/classifier_tutorial.rst
index 8de63d8..28671a3 100644
--- a/doc/source/classifier_tutorial.rst
+++ b/doc/source/classifier_tutorial.rst
@@ -33,7 +33,7 @@ from scikit-learn and splitting it into training and testing sets using the
 
 
 Training and Evaluating The Classifiers
----------------------------------------
+=======================================
 
 Next, we define three scikit-learn classifiers: K nearest neighbors
 (:class:`~sklearn.neighbors.KNeighborsClassifier`), a neural network model
@@ -66,7 +66,7 @@ distribution of accuracies to later compare the confidence intervals. To
 achieve this, we can use `bootstrapping <https://en.wikipedia.org/wiki/Bootstrapping_(statistics)/>`_.
 
 Bootstrapping and Confidence Intervals
---------------------------------------
+======================================
 
 We define a function ``bootstrap_model_accuracy`` that resamples the test
 set *with* replacement and calculates the accuracy of each classifier on
@@ -124,7 +124,7 @@ We can now go ahead and also plot our bootstrapped results as histograms.
     plt.title("Distributions of model accuracy for different models")
     plt.legend()
 
-.. image:: images/classifier_tutorial.png
+.. image:: _static/classifier_tutorial.png
     :width: 600
     :align: center
     :alt: Classifier Tutorial
diff --git a/doc/source/conf.py b/doc/source/conf.py
index 7cafb72..e5e8c9f 100644
--- a/doc/source/conf.py
+++ b/doc/source/conf.py
@@ -177,5 +177,8 @@ def setup(app):
     ),
     "torch": ("https://pytorch.org/docs/stable/",
               "https://pytorch.org/docs/stable/objects.inv"
+    ),
+    "nilearn": ("https://nilearn.github.io/stable/",
+                "https://nilearn.github.io/stable/objects.inv"
     )
 }
diff --git a/doc/source/connectome_tutorial.rst b/doc/source/connectome_tutorial.rst
new file mode 100644
index 0000000..8065898
--- /dev/null
+++ b/doc/source/connectome_tutorial.rst
@@ -0,0 +1,195 @@
+.. Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+.. in Cooperation with Max Planck Society
+
+.. SPDX-License-Identifier: CC-BY-NC-SA-1.0
+
+:orphan:
+
+Functional Connectivity Tutorial
+--------------------------------
+
+.. note::
+    These examples were run on the ESI HPC cluster. This is why we use
+    :func:`~acme.esi_cluster_setup` to set up a parallel computing client.
+    They are perfectly reproducible on any other cluster or local machine
+    by instead using :func:`~acme.slurm_cluster_setup` or :func:`~acme.local_cluster_setup`
+    respectively.
+
+The following Python code demonstrates how to use ACME to concurrently compute the
+`functional connectivity <https://en.wikipedia.org/wiki/Resting_state_fMRI#Functional>`_
+in fMRI datasets of multiple subjects.
+
+We start by downloading a publicly available fMRI dataset comprising
+`BOLD <https://en.wikipedia.org/wiki/Blood-oxygen-level-dependent_imaging>`_
+signals from 33 adults using the
+`Nilearn <https://nilearn.github.io/stable/index.html>`_ package:
+
+.. code-block:: python
+
+    from nilearn import datasets
+
+    data = datasets.fetch_development_fmri(age_group="adult")
+
+The brain regions of interest (ROI) are taken from
+`Power et al. 2011 <https://doi.org/10.1016/j.neuron.2011.09.006>`_ comprising
+an atlas of 264 areas.
+
+.. code-block:: python
+
+    from nilearn import plotting
+
+    atlas = datasets.fetch_coords_power_2011(legacy_format=False)
+
+Next, we extract ROI-averaged time-series from the downloaded fMRI data.
+To do this, we first use the
+`MNI <https://en.wikipedia.org/wiki/Talairach_coordinates#Montreal_Neurological_Institute_(MNI)_templates>`_
+coordinates of every ROI in the atlas as seeds for masking the fMRI data:
+
+.. code-block:: python
+
+    from nilearn.maskers import NiftiSpheresMasker
+
+    atlasCoords = np.vstack((atlas.rois['x'],
+                             atlas.rois['y'],
+                             atlas.rois['z'])).T
+    masker = NiftiSpheresMasker(seeds=atlasCoords, smoothing_fwhm=6,
+                                radius=5., detrend=True,
+                                standardize=True,
+                                low_pass=0.1, high_pass=0.01, t_r=2)
+
+Next, we pick a random subject (Number 0) and perform the signal extraction
+removing nuisance regressors shipped with the fMRI dataset
+
+.. code-block:: python
+
+    timeseries = masker.fit_transform(data.func[0],
+                                      confounds=data.confounds[0])
+
+The extracted BOLD time-courses are contained in a ``(#time-points x #ROIs)`` = ``(168 x 264)`` array.
+We choose `partial correlation <https://en.wikipedia.org/wiki/Partial_correlation>`_
+as statistical dependence metric for computing the functional connectivity
+across all 264 regions in our atlas.
+Specifically, we compute the sparse inverse covariance matrix of the BOLD
+data with a cross-validated choice of the :math:`\ell^1`-penalty using
+scikit-learn's :class:`~sklearn.covariance.GraphicalLassoCV`
+
+.. code-block:: python
+
+    from sklearn.covariance import GraphicalLassoCV
+
+    estimator = GraphicalLassoCV()
+    estimator.fit(timeseries)
+
+To visualize the computed inter-areal functional connectivity of Subject #0,
+we use Nilearns :func:`~nilearn.plotting.plot_connectome`
+
+.. code-block:: python
+
+    from nilearn import plotting
+
+    plotting.plot_connectome(estimator.covariance_, atlasCoords,
+                             title=f"Subject #{subIdx}",
+                             edge_threshold="95%", node_size=20,
+                             colorbar=True, edge_vmin=-1, edge_vmax=1,
+                             figure=fig)
+
+.. image:: _static/subject0.png
+    :width: 75%
+    :align: center
+    :alt: Subject 0 FC
+
+Functional Connectivity in Parallel
+===================================
+
+In order to compute the functional connectome of multiple subjects concurrently,
+we first capsulate the computational steps in a Python function called
+``compute_connectome`` which we define inside a dedicated module ``connectome.py``:
+
+.. code-block:: python
+
+   import numpy as np
+   from nilearn import datasets
+   from nilearn.maskers import NiftiSpheresMasker
+   from sklearn.covariance import GraphicalLassoCV
+   from numpy.typing import NDArray
+
+   # Format atlas coordinates
+   atlas = datasets.fetch_coords_power_2011(legacy_format=False)
+   atlasCoords = np.vstack((atlas.rois['x'],
+                            atlas.rois['y'],
+                            atlas.rois['z'])).T
+
+   def compute_connectome(subidx : int) -> NDArray[np.float64]:
+       """
+       Compute functional connectome of single subject
+
+       Parameters
+       ----------
+       subidx : int
+           Subject number
+
+       Returns
+       -------
+       con : 2D np.ndarray
+           Functional connectivity matrix
+       """
+
+       # Take stock of data on disk
+       data = datasets.fetch_development_fmri(age_group="adult")
+
+       # Extract fMRI time-series averaged within spheres @ atlas coords
+       masker = NiftiSpheresMasker(seeds=atlasCoords, smoothing_fwhm=6,
+                                   radius=5., detrend=True,
+                                   standardize=True, low_pass=0.1,
+                                   high_pass=0.01, t_r=2)
+       timeseries = masker.fit_transform(data.func[subidx],
+                                         confounds=data.confounds[subidx])
+
+       # Compute functional connectivity b/w brain regions
+       estimator = GraphicalLassoCV()
+       estimator.fit(timeseries)
+       return estimator.covariance_
+
+We now define a set of subjects to analyze and set up a parallel computing
+client to do the actual processing
+
+.. code-block:: python
+
+   from connectome import compute_connectome
+   from acme import esi_cluster_setup, ParallelMap
+
+   subjectList = list(set(range(20)).difference([7, 8, 11, 12, 19]))
+   myClient = esi_cluster_setup(n_workers=8, mem_per_worker="8GB",
+                                cores_per_worker=2, partition="E880")
+
+
+The return value of ``compute_connectome`` is always a
+``(#ROIs x #ROIs)`` = ``(264 x 264)`` matrix for all subjects. Thus, we
+want to store the computed functional connectivity matrices in a single
+shared 3d-array for easier post-processing. We use :class:`~acme.ParallelMap`'s
+``result_shape`` keyword for that:
+
+.. code-block:: python
+
+   with ParallelMap(compute_connectome,
+                    subjectList,
+                    result_shape=(264, 264, None)) as pmap:
+       pmap.compute()
+
+The resulting array can be accessed like any other HDF5 dataset, e.g., to
+visualize the inter-areal functional connectivity of Subject #4, we
+can use
+
+.. code-block:: python
+
+   import h5py
+
+   sub4 = h5py.File(pmap.results_container, "r")["result_3"][()]
+   plotting.plot_connectome(estimator.covariance_, atlasCoords,
+                            title=f"Subject #{subIdx}",
+                            edge_threshold="95%", node_size=20,
+                            colorbar=True, edge_vmin=-1, edge_vmax=1,
+                            figure=fig)
+
+For more information about using ``result_shape`` and virtual HDF5 datasets,
+see `Collect Results in Single Dataset <https://esi-acme.readthedocs.io/en/latest/userguide.html#collect-results-in-single-dataset>`_
diff --git a/doc/source/contributing.rst b/doc/source/contributing.rst
index ad7000b..108dd21 100644
--- a/doc/source/contributing.rst
+++ b/doc/source/contributing.rst
@@ -36,7 +36,8 @@ you want to set up a Windows environment )
        conda env create -f acme.yml
 
 4. Activate the environment and run ACME's test-suite to ensure your system
-   is set up correctly
+   is set up correctly (for more details see ACME's
+   `testing-README <https://github.com/esi-neuroscience/acme/blob/master/acme/tests/README.md>`_)
 
    .. tabs::
 
@@ -74,7 +75,35 @@ We use feature branches for development with a dedicated development branch
 release.
 To get your feature included in ACME's upstream code, please create a new
 branch from ``[dev]`` in your fork and push your work to this branch. Once
-you're done with your changes, please open a
+you're done with your changes, please launch a full test-run to ensure
+ACME's proper functionality:
+
+.. tabs::
+
+    .. tab:: Linux/macOS
+
+        .. code-block:: shell
+
+            cd tests/
+            ./run_tests.sh pytest
+
+    .. tab:: Windows
+
+        .. code-block:: shell
+
+            cd tests/
+            run_tests.cmd pytest
+
+Similarly, please use a static code checker to verify ACME's source code integrity.
+By default, our development environment comes with `mypy`. To perform
+the static code analysis with `mypy`, open a terminal, go to the root directory of
+your local ACME repository and run:
+
+.. code-block:: shell
+
+    mypy acme --allow-redefinition
+
+Once all checks pass and you are happy with your modifications please open a
 `pull request <https://github.com/esi-neuroscience/acme/pulls>`_
 for merging in the ``[dev]`` branch of ACME's
 `main repository <https://github.com/esi-neuroscience/acme>`_.
@@ -103,7 +132,7 @@ and simply un-comment the auto-generated "Changes to be committed" section.
 To further help others retrace our steps, we usually refer to GitHub issues
 by their number prepended with the "#" symbol. Here's an example:
 
-.. code-block:: bash
+.. code-block::
 
     FIX: Addresses bug in this_function
 
@@ -144,7 +173,7 @@ We keep track of changes in ACME by keeping a global changelog inspired
 by `Keep a Changelog <https://keepachangelog.com>`_. The structure of
 ACME's changelog follows the exemplary layout below:
 
-.. code-block:: bash
+.. code-block::
 
     # Changelog of ACME
     All notable changes to this project will be documented in this file.
diff --git a/doc/source/deeplearning_tutorial.rst b/doc/source/deeplearning_tutorial.rst
index 76dde5a..3b1b11a 100644
--- a/doc/source/deeplearning_tutorial.rst
+++ b/doc/source/deeplearning_tutorial.rst
@@ -41,8 +41,8 @@ First, we import the necessary packages:
     import itertools
 
 
-Building the Network & Related Runctions
-----------------------------------------
+Building the Network & Related Functions
+========================================
 
 Next, we define our ``NeuralNet`` model. We will use a simple fully connected
 network with 5 hidden layers and vary the number of units in each layer.
@@ -128,7 +128,8 @@ necessary here.
 
 
 Getting the Data Ready
------------------------
+======================
+
 We pass each PyTorch :class:`~torch.utils.data.DataLoader` along with the
 model parameters to :class:`~acme.ParallelMap`.
 
diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst
index 913d096..bdc78a5 100644
--- a/doc/source/quickstart.rst
+++ b/doc/source/quickstart.rst
@@ -86,7 +86,7 @@ by running :func:`~acme.slurm_cluster_setup` (on an HPC cluster managed by the
     from acme import slurm_cluster_setup
 
     slurmClient = slurm_cluster_setup(partition="some_partition",
-                                      n_cores=2,
+                                      cores_per_worker=2,
                                       mem_per_worker="2GB",
                                       n_workers=10)
 
diff --git a/doc/source/tutorials.rst b/doc/source/tutorials.rst
index 46efad7..10f00be 100644
--- a/doc/source/tutorials.rst
+++ b/doc/source/tutorials.rst
@@ -11,6 +11,7 @@ to tackle real-world problems.
 
 - :doc:`Parallel Bootstrapping with ACME <classifier_tutorial>`
 - :doc:`Deep Learning with ACME <deeplearning_tutorial>`
+- :doc:`Functional Connectivity with ACME <connectome_tutorial>`
 
 You have used ACME in your productive work too? We would love to hear from you!
 If you want to share your experience with ACME in form of a tutorial here,
diff --git a/pyproject.toml b/pyproject.toml
index f3d5c39..816b16f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,5 +6,16 @@
 #
 
 [build-system]
-requires = ["pyyaml", "setuptools", "setuptools_scm", "toml", "wheel"]
+requires = ["pyyaml", "setuptools", "toml", "wheel"]
 build-backend = "setuptools.build_meta"
+
+[[tool.mypy.overrides]]
+module = [
+    "dask_jobqueue",
+    "h5py",
+    "psutil",
+    "scipy",
+    "tqdm",
+    "frontend",
+]
+ignore_missing_imports = true
diff --git a/setup.cfg b/setup.cfg
index a3fcbbc..421f4be 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -13,7 +13,7 @@ description = Asynchronous Computing Made ESI
 long_description = file: README.md
 long_description_content_type = text/markdown; charset=UTF-8
 license = BSD-3
-license_files = LICENSE
+license_files = LICENSES/*
 home_page = https://esi-acme.readthedocs.io/en/latest/
 project_urls =
     Bug Tracker = https://github.com/esi-neuroscience/acme/issues
@@ -31,28 +31,36 @@ classifier =
     Programming Language :: Python :: 3.8
     Programming Language :: Python :: 3.9
     Programming Language :: Python :: 3.10
+    Programming Language :: Python :: 3.11
+    Programming Language :: Python :: 3.12
     Programming Language :: Python :: Implementation :: CPython
 
 [options]
 packages = find:
 include_package_data = True
 zip_safe = False
-python_requires = >= 3.7, < 3.11
+python_requires =
+    >= 3.7, < 3.13
 install_requires =
+    cloudpickle <= 2.2.1
     colorama
-    dask >= 2021.09.0, < 2023.4
+    dask >= 2021.09.0, <= 2023.12
     dask-jobqueue >= 0.8, < 0.9
     h5py < 4
     numpy >= 1.0, < 2.0
+    msgpack < 1.0.7
+    toolz >= 0.12
     tqdm >= 4.31
 
 [options.extras_require]
 dev=
     ipdb
-    ipython
+    ipython <= 8.15
     scipy >= 1.5, < 2.0
+    mypy
     numpydoc
     pytest-cov
+    reuse
     sphinx < 6
     sphinx_automodapi
     sphinx_bootstrap_theme
diff --git a/setup.py b/setup.py
index a40ca83..c901942 100644
--- a/setup.py
+++ b/setup.py
@@ -9,15 +9,13 @@
 import datetime
 from setuptools import setup
 from setuptools.config.setupcfg import read_configuration
-import subprocess
 import toml
 
 # External packages
 import yaml
-from setuptools_scm import get_version
 
-# Set release version by hand
-releaseVersion = "2023.4"
+# Manually set release version
+releaseVersion = "2023.12"
 
 # Read dependencies from setup.cfg + pyproject.toml and create conda environment file
 envFile = "acme.yml"
@@ -28,6 +26,11 @@
 for k in range(len(pipPkgs)):
     pkg = pipPkgs[k]
     pipPkgs[k] = allPkgs.pop([allPkgs.index(dep) for dep in allPkgs if pkg in dep][0])
+condaMap = {"msgpack" : "msgpack-python"}
+for pipName, condaName in condaMap.items():
+    depIdx = [allPkgs.index(dep) for dep in allPkgs if pipName in dep][0]
+    depName = allPkgs[depIdx]
+    allPkgs[depIdx] = depName.replace(pipName, condaName)
 pyVer = setupOpts
 allPkgs += ["python " + str(setupOpts["python_requires"]), "pip", {"pip" : pipPkgs}]
 ymlDict = {"name" : "acme",
@@ -41,32 +44,18 @@
     "# Do not edit, all of your changes will be overwritten. \n"
 header = header.format(datetime.datetime.now().strftime("%Y"),
                        datetime.datetime.now().strftime("%d/%m/%Y at %H:%M:%S"))
-with open(envFile, "w") as ymlFile:
+with open(envFile, "w", encoding="utf8") as ymlFile:
     ymlFile.write(header)
     yaml.dump(ymlDict, ymlFile, default_flow_style=False)
 
-# If code has not been obtained via `git` or we're inside the main branch,
-# use the hard-coded `releaseVersion` as version. Otherwise keep the local `tag.devx`
-# scheme for TestPyPI uploads
-proc = subprocess.run("git branch --show-current", shell=True, capture_output=True, text=True)
-if proc.returncode !=0 or proc.stdout.strip() == "main":
-    version = releaseVersion
-    versionKws = {"use_scm_version" : False, "version" : version}
-else:
-    version = get_version(root='.', relative_to=__file__, local_scheme="no-local-version")
-    versionKws = {"use_scm_version" : {"local_scheme": "no-local-version"}}
-
 # Update citation file
 citationFile = "CITATION.cff"
-with open(citationFile) as ymlFile:
+with open(citationFile, "r", encoding="utf8") as ymlFile:
     ymlObj = yaml.safe_load(ymlFile)
-ymlObj["version"] = version
+ymlObj["version"] = releaseVersion
 ymlObj["date-released"] = datetime.datetime.now().strftime("%Y-%m-%d")
-with open(citationFile, "w") as ymlFile:
+with open(citationFile, "w", encoding="utf8") as ymlFile:
     yaml.dump(ymlObj, ymlFile)
 
 # Run setup (note: identical arguments supplied in setup.cfg will take precedence)
-setup(
-    setup_requires=['setuptools_scm'],
-    **versionKws
-)
+setup(version=releaseVersion)
diff --git a/tox.ini b/tox.ini
index 646ef74..af7e450 100644
--- a/tox.ini
+++ b/tox.ini
@@ -6,7 +6,8 @@
 #
 
 [tox]
-envlist = {py38,py39,py310}
+; envlist = {py312}
+envlist = {py310,py311,py312}
 requires = tox-conda
 isolated_build = True
 
@@ -14,19 +15,21 @@ isolated_build = True
 recreate = True
 changedir = acme/tests
 passenv = *
+setenv =
+    INTOX = 1
 allowlist_externals = sh
 # Force tox to install major package dependencies via conda to avoid building
 # larger packages on POWER via pip
 conda_deps=
-    ipython
     tqdm >= 4.31
     numpy >= 1.0, < 2.0
     scipy >= 1.5, < 2.0
     h5py < 4
-    dask >= 2021.09.0, < 2023.4
+    dask == 2023.12
     dask-jobqueue >= 0.8, < 0.9
     bokeh
     pytest
+    ipython
 conda_channels=
     defaults
     conda-forge
@@ -34,4 +37,4 @@ commands_pre =
     python -c "import sys; import subprocess; subprocess.run('chmod +x no_slurm.sh', shell=True, check=True) if sys.platform == 'linux' else print('not on linux')"
     python -c "import sys; import subprocess; subprocess.run('./no_slurm.sh', shell=True, check=True) if sys.platform == 'linux' else print('not on linux')"
 commands =
-    pytest
+    pytest --color=yes --tb=short --verbose


Test patch:
diff --git a/.github/workflows/tests_workflow.yml b/.github/workflows/tests_workflow.yml
index 69acae4..171908b 100644
--- a/.github/workflows/tests_workflow.yml
+++ b/.github/workflows/tests_workflow.yml
@@ -22,11 +22,11 @@ jobs:
     strategy:
       max-parallel: 5
     steps:
-    - uses: actions/checkout@v2
-    - name: Set up Python 3.8
-      uses: actions/setup-python@v2
+    - uses: actions/checkout@v4
+    - name: Set up Python 3.12
+      uses: actions/setup-python@v4
       with:
-        python-version: 3.8
+        python-version: '3.12'
     - name: Install ACME
       run: |
         pip install -e .[dev]
@@ -37,6 +37,6 @@ jobs:
         flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
         # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
         flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
-    - name: Test with pytest and get coverage
+    - name: Test with pytest
       run: |
-        pytest --verbose
+        pytest --color=yes --tb=short --verbose
diff --git a/acme/tests/README.md b/acme/tests/README.md
new file mode 100644
index 0000000..283c27c
--- /dev/null
+++ b/acme/tests/README.md
@@ -0,0 +1,73 @@
+<!--
+Copyright (c) 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+in Cooperation with Max Planck Society
+SPDX-License-Identifier: CC-BY-NC-SA-1.0
+-->
+
+# ACME Test Pipeline
+
+This directory contains ACME's test suite. Tests can be run either locally
+or on HPC cluster nodes.
+
+## Run Entire Suite
+
+If ACME is installed in your `site-packages`, you can run all tests directly
+from your Python interpreter
+
+```python
+import pytest
+pytest_args = ["-v", "--pyargs", "acme"]
+pytest.main(pytest_args)
+```
+
+If you're working on a local clone of ACME, the convenience script
+[run_tests.sh](./run_tests.sh) can take care of setting up and
+launching ACME's test-suite. Running the script without arguments shows
+a brief usage summary:
+
+```bash
+cd acme/acme/tests
+./run_tests.sh
+```
+
+Pick an option and start testing, e.g.,
+
+```bash
+./run_tests.sh pytest
+```
+
+## Run Single Tests
+
+If ACME is installed in your `site-packages`, simply use the following
+code snippet to run a single test, e.g., `test_simple_filter`
+
+```python
+import pytest
+pytest_args = ["-v", "--pyargs", "acme", "-k", "test_simple_filter"]
+pytest.main(pytest_args)
+```
+
+If ACME is **not** installed in your `site-packages` directory, open a
+terminal and add ACME to your Python path first:
+
+```bash
+cd acme/acme/tests/
+export PYTHONPATH=$(cd ../../ && pwd)
+```
+
+Then pick a testing function and run it, e.g., to execute the `test_simple_filter`
+test, you can use
+
+```bash
+pytest -v test_pmap.py -k 'test_simple_filter'
+```
+
+Use pytest's extensive [command line arguments](https://docs.pytest.org/en/6.2.x/usage.html)
+to accommodate your development workflow. For instance, to show ACME's
+command line output, use the `-s` flag, to drop to
+[pdb](https://docs.python.org/3/library/pdb.html) on failures, additionally
+provide the `--pdb` option
+
+```bash
+pytest -sv test_pmap.py -k 'test_simple_filter' --pdb
+```
diff --git a/acme/tests/conftest.py b/acme/tests/conftest.py
index c20bf78..6f9621e 100644
--- a/acme/tests/conftest.py
+++ b/acme/tests/conftest.py
@@ -12,7 +12,7 @@
 import pytest
 
 # Import main actors here
-from acme.shared import is_slurm_node, is_esi_node
+from acme.shared import is_slurm_node, is_esi_node, is_x86_node
 
 # Construct decorators for skipping certain tests
 skip_if_not_linux = pytest.mark.skipif(sys.platform != "linux", reason="Only works in Linux")
@@ -23,8 +23,14 @@
 # Perform ESI-specific tests only the ESI HPC cluster
 onESI = is_esi_node()
 
+# Perform machine-architecture dependent tests only on appropriate hardware
+onx86 = is_x86_node()
+
 # Use a default partition if running on the ESI cluster
 if onESI:
-    defaultQ ="8GBXS"
+    if onx86:
+        defaultQ = "8GBXS"
+    else:
+        defaultQ = "E880"
 else:
-    defaultQ ="auto"
+    defaultQ = "auto"
diff --git a/acme/tests/local_acme.py b/acme/tests/local_acme.py
index f4a7037..99e4a55 100644
--- a/acme/tests/local_acme.py
+++ b/acme/tests/local_acme.py
@@ -18,39 +18,41 @@
     sys.path.insert(0, acme_path)
 
 # Import package
-from acme import ParallelMap
+from acme import ParallelMap, esi_cluster_setup
 
-def f(x, y, z=3, w=np.zeros((3, 1)), **kwargs):
-    return (x + y) * z * w.max()
 
-import time
 
-def g(x, y, z=3):
-    fSize = np.dtype("float").itemsize
-    arrSize = 4
-    time.sleep(10)
-    arr = np.ones((int(arrSize * 1024**3 / fSize), ))
-    time.sleep(300)
-    return (sum(x) + y) * z * arr.max()
+# def f(x, y, z=3, w=np.zeros((3, 1)), **kwargs):
+#     return (x + y) * z * w.max()
 
-def arr_test(x, y):
-    return x + y
+# import time
 
-# Prepare code to be executed using, e.g., iPython's `%run` magic command
-if __name__ == "__main__":
+# def g(x, y, z=3):
+#     fSize = np.dtype("float").itemsize
+#     arrSize = 4
+#     time.sleep(10)
+#     arr = np.ones((int(arrSize * 1024**3 / fSize), ))
+#     time.sleep(300)
+#     return (sum(x) + y) * z * arr.max()
 
-    # Test stuff within here...
-    # pass
+# def arr_test(x, y):
+#     return x + y
 
-    with ParallelMap(arr_test, [np.ones((20,)), 2 * np.ones((20,)), 3 * np.ones((20,))], 4, result_shape=(None, 20), verbose=True, single_file=True) as pmap:
-        results = pmap.compute()
+# # Prepare code to be executed using, e.g., iPython's `%run` magic command
+# if __name__ == "__main__":
 
-    1/0
+#     # Test stuff within here...
+#     # pass
 
-    # pmap = ParallelMap(g, np.arange(100), 2)
-    # pmap.daemon.estimate_memuse()
+#     with ParallelMap(arr_test, [np.ones((20,)), 2 * np.ones((20,)), 3 * np.ones((20,))], 4, result_shape=(None, 20), verbose=True, single_file=True) as pmap:
+#         results = pmap.compute()
 
-    # # with pmap as p:
-    # #     p.compute()
+#     1/0
+
+#     # pmap = ParallelMap(g, np.arange(100), 2)
+#     # pmap.daemon.estimate_memuse()
+
+#     # # with pmap as p:
+#     # #     p.compute()
 
 
diff --git a/acme/tests/run_tests.sh b/acme/tests/run_tests.sh
index 5ea047d..32f81ea 100755
--- a/acme/tests/run_tests.sh
+++ b/acme/tests/run_tests.sh
@@ -41,6 +41,20 @@ if [ "$1" == "" ]; then
     usage
 fi
 
+# Define default SLURM partition based on architecture we're running on
+mArch=`uname -m`
+if [ "${mArch}" == "x86_64" ]; then
+    pytestQ="8GBL"
+    pytestCPU=1
+    toxQ="16GBL"
+    toxCPU=2
+else
+    pytestQ="E880"
+    pytestCPU=4
+    toxQ="E880"
+    toxCPU=8
+fi
+
 # Set up "global" pytest options for running test-suite (coverage is only done in local pytest runs)
 export PYTEST_ADDOPTS="--color=yes --tb=short --verbose"
 
@@ -51,26 +65,26 @@ while [ "$1" != "" ]; do
             shift
             export PYTHONPATH=$(cd ../../ && pwd)
             if [ $_useSLURM ]; then
-                CMD="srun -p 8GBDEV --mem=4000m -c 4 pytest"
+                CMD="srun -u -p ${pytestQ} --mem=8000m -c ${pytestCPU} pytest"
             else
-                PYTEST_ADDOPTS="$PYTEST_ADDOPTS --cov=../../acme --cov-config=../../.coveragerc"
+                PYTEST_ADDOPTS="${PYTEST_ADDOPTS} --cov=../../acme --cov-config=../../.coveragerc"
                 export PYTEST_ADDOPTS
                 CMD="pytest"
             fi
             echo ">>>"
-            echo ">>> Running $CMD $PYTEST_ADDOPTS"
+            echo ">>> Running ${CMD} ${PYTEST_ADDOPTS}"
             echo ">>>"
             ${CMD}
             ;;
         tox)
             shift
             if [ $_useSLURM ]; then
-                CMD="srun -p DEV --mem=8000m -c 4 tox"
+                CMD="srun -u -p ${toxQ} --mem=8000m -c ${toxCPU} tox"
             else
                 CMD="tox"
             fi
             echo ">>>"
-            echo ">>> Running $CMD "
+            echo ">>> Running ${CMD} "
             echo ">>>"
             ${CMD}
             ;;
diff --git a/acme/tests/test_dask.py b/acme/tests/test_dask.py
index d1e87f7..899f79a 100644
--- a/acme/tests/test_dask.py
+++ b/acme/tests/test_dask.py
@@ -11,10 +11,12 @@
 import pytest
 import getpass
 import numpy as np
+import dask.distributed as dd
 
 # Import main actors here
 from acme import cluster_cleanup, esi_cluster_setup, slurm_cluster_setup, local_cluster_setup
-from conftest import useSLURM, onESI, defaultQ
+from conftest import useSLURM, onESI, onx86, defaultQ
+
 
 def test_cluster_setup():
 
@@ -33,6 +35,38 @@ def test_cluster_setup():
         cluster_cleanup()
         with pytest.raises(ValueError):
             slurm_cluster_setup(partition=defaultQ, mem_per_worker="-110MB")
+        with pytest.raises(ValueError):
+            slurm_cluster_setup(partition=defaultQ, mem_per_worker="1000XB")
+        cluster_cleanup()
+
+        # Ensure invalid worker/process/core count specifications are caught
+        with pytest.raises(ValueError):
+            slurm_cluster_setup(partition=defaultQ, n_workers=-3)
+        cluster_cleanup()
+        with pytest.raises(ValueError):
+            slurm_cluster_setup(partition=defaultQ, n_workers_startup=-3)
+        cluster_cleanup()
+        with pytest.raises(ValueError):
+            slurm_cluster_setup(partition=defaultQ, processes_per_worker=-3)
+        cluster_cleanup()
+        with pytest.raises(ValueError):
+            slurm_cluster_setup(partition=defaultQ, n_cores=-3)
+        cluster_cleanup()
+
+        # Ensure invalid timeouts are caught
+        with pytest.raises(ValueError):
+            slurm_cluster_setup(partition=defaultQ, timeout=-3)
+        cluster_cleanup()
+        with pytest.raises(ValueError):
+            slurm_cluster_setup(partition=defaultQ, interactive_wait=-3)
+        cluster_cleanup()
+
+        # Test Booleans
+        with pytest.raises(TypeError):
+            slurm_cluster_setup(partition=defaultQ, interactive=3)
+        cluster_cleanup()
+        with pytest.raises(TypeError):
+            slurm_cluster_setup(partition=defaultQ, start_client=3)
         cluster_cleanup()
 
         # Ensure additional sbatch parameters are processed correctly
@@ -42,16 +76,26 @@ def test_cluster_setup():
         with pytest.raises(ValueError):
             slurm_cluster_setup(partition=defaultQ, job_extra=["invalid"])
         cluster_cleanup()
+        with pytest.raises(TypeError):
+            slurm_cluster_setup(partition=defaultQ, job_extra=[3])
+        cluster_cleanup()
         client = slurm_cluster_setup(partition=defaultQ,
                                      job_extra=["--job-name='averycustomjobname'"],
                                      interactive=False)
         assert 'averycustomjobname' in client.cluster.job_header
         cluster_cleanup()
 
+        # Trigger timeout errors
+        with pytest.raises(TimeoutError):
+            slurm_cluster_setup(partition=defaultQ, n_workers=1, timeout=1, interactive=False)
+
         # Ensure output directory specification is parsed for correctness
         with pytest.raises(ValueError):
             slurm_cluster_setup(partition=defaultQ, job_extra=["--output=/path/to/nowhere"])
         cluster_cleanup()
+        with pytest.raises(ValueError):
+            slurm_cluster_setup(partition=defaultQ, job_extra=["--output=invalid=path"])
+        cluster_cleanup()
         slurmOut = "/tmp/{}".format(getpass.getuser())
         client = slurm_cluster_setup(partition=defaultQ,
                                      n_workers=1,
@@ -68,28 +112,70 @@ def test_cluster_setup():
             # Over-allocation of memory should default to partition max
             # (this should work on all clusters but we don't know partition
             # names, QoS rules etc.)
-            client = esi_cluster_setup(partition="8GBDEV",
-                                       timeout=120,
-                                       n_workers=1,
-                                       mem_per_worker="9000MB",
-                                       interactive=False)
-            memory = np.unique([w["memory_limit"] for w in client.cluster.scheduler_info["workers"].values()])
-            assert memory.size == 1
-            assert np.round(memory / 1000**3)[0] == 8
-
-            # Invoking `esi_cluster_setup` with existing client must not start a new one
-            clnt = esi_cluster_setup(partition="16GBXS", n_workers=2, interactive=False)
-            assert clnt == client
-            cluster_cleanup(client)
+            if onx86:
+                client = esi_cluster_setup(partition="8GBDEV",
+                                        timeout=120,
+                                        n_workers=1,
+                                        mem_per_worker="9000MB",
+                                        interactive=False)
+                memory = np.unique([w["memory_limit"] for w in client.cluster.scheduler_info["workers"].values()])
+                assert memory.size == 1
+                assert np.round(memory / 1000**3)[0] == 8
+
+                # Invoking `esi_cluster_setup` with existing client must not start a new one
+                clnt = esi_cluster_setup(partition="16GBXS", n_workers=2, interactive=False)
+                assert clnt == client
+                cluster_cleanup(client)
+
+                # Attempt to start a client on ppc64le
+                with pytest.raises(ValueError) as valerr:
+                    esi_cluster_setup(partition="E880")
+                    assert "ppc64le from submitting host with architecture x86_64" in str(valerr.value)
+
+            else:
+
+                # Attempt to start a client on x86
+                with pytest.raises(ValueError) as valerr:
+                    esi_cluster_setup(partition="8GBXS")
+                    assert "x86_64 from submitting host with architecture ppc64le" in str(valerr.value)
 
             # Specify CPU count manually
-            client = esi_cluster_setup(partition="16GBDEV",
+            client = esi_cluster_setup(partition=defaultQ,
                                        timeout=120,
                                        n_workers=1,
-                                       n_cores=1,
+                                       cores_per_worker=1,
                                        interactive=False)
             assert [w["nthreads"] for w in client.cluster.scheduler_info["workers"].values()][0] == 1
-            cluster_cleanup(client)
+
+            # Kill worker in client to trigger cleanup and startup of new
+            # cluster when re-invoking setup routine
+            client.retire_workers(list(client.scheduler_info()['workers']), close_workers=True)
+            cluster = esi_cluster_setup(defaultQ,
+                                        n_workers=1,
+                                        mem_per_worker="1GB",
+                                        start_client=False,
+                                        interactive=False)
+            memory = np.unique([w["memory_limit"] for w in cluster.scheduler_info["workers"].values()])
+            assert memory.size == 1
+            assert np.round(memory / 1000**3)[0] == 1
+
+            # Ensure no client was started w/previous call
+            with pytest.raises(ValueError):
+                dd.get_client()
+
+            # Manually close cluster
+            client = dd.Client(cluster)
+            client.retire_workers(list(client.scheduler_info()['workers']), close_workers=True)
+            client.close()
+            try:
+                cluster.close()
+            except:
+                pass
+
+            # Ensure job-list parsing works
+            with pytest.raises(TypeError) as tperr:
+                esi_cluster_setup(partition=defaultQ, job_extra="invalid")
+                assert "`job_extra` has to be a list, not <class 'str'>" in str(tperr.value)
 
     else:
 
@@ -98,6 +184,10 @@ def test_cluster_setup():
             slurm_cluster_setup()
             assert "Cannot access SLURM queuing system" in str(err)
 
+    # Check if `cluster_cleanup` performs diligent error checking
+    with pytest.raises(TypeError):
+        cluster_cleanup(3)
+
 
 def test_local_setup():
 
@@ -112,20 +202,34 @@ def test_local_setup():
 
     # Allocate local distributed computing client w/default settings
     client = local_cluster_setup(interactive=False)
-    assert len(client.cluster.scheduler_info["workers"].keys()) > 1
+    assert len(client.cluster.scheduler_info["workers"].keys()) >= 1
+    cluster_cleanup()
+
+    # Ensure error handling works
+    with pytest.raises(TypeError):
+        local_cluster_setup(interactive="invalid")
     cluster_cleanup()
 
 
 def test_backcompat_cluster():
 
     if useSLURM:
-        client = slurm_cluster_setup(partition=defaultQ,
-                                     timeout=120,
-                                     n_jobs=1,
-                                     workers_per_job=1,
-                                     mem_per_job="1GB",
-                                     n_jobs_startup=1,
-                                     interactive=False)
+        if onESI:
+            client = esi_cluster_setup(partition=defaultQ,
+                                       timeout=120,
+                                       n_jobs=1,
+                                       workers_per_job=1,
+                                       mem_per_job="1GB",
+                                       n_jobs_startup=1,
+                                       interactive=False)
+        else:
+            client = slurm_cluster_setup(partition=defaultQ,
+                                         timeout=120,
+                                         n_jobs=1,
+                                         workers_per_job=1,
+                                         mem_per_job="1GB",
+                                         n_jobs_startup=1,
+                                         interactive=False)
         assert len(client.cluster.workers) == 1
         memory = [w["memory_limit"] for w in client.cluster.scheduler_info["workers"].values()]
         assert round(memory[0] / 1000**3) == 1
diff --git a/acme/tests/test_internals.py b/acme/tests/test_internals.py
new file mode 100644
index 0000000..c454a0b
--- /dev/null
+++ b/acme/tests/test_internals.py
@@ -0,0 +1,128 @@
+#
+# Testing module for non-user exposed functionality in ACME
+#
+# Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: BSD-3-Clause
+#
+
+# Builtin/3rd party package imports
+import os
+import shutil
+import pytest
+import numpy as np
+import logging
+from logging import handlers
+
+# Local imports
+import acme
+from acme.shared import sizeOf, callMax, _scalar_parser
+from acme.logger import prepare_log
+
+def test_sizeof():
+
+    # Ensure self-referencing dict does not lock up ACME (decrease callMax
+    # to not trigger segfaults)
+    infDict = {}; infDict["key"] = infDict
+    acme.shared.callMax = 100
+    with pytest.raises(RecursionError) as recerr:
+        sizeOf(infDict, "infdict")
+        assert f"maximum recursion depth {acme.shared.callMax} exceeded" in str(recerr.value)
+    acme.shared.callMax = callMax
+
+    # Ensure well-behaved dicts are processed correctly (`arrsize` denotes array size in MB)
+    arrsize = 100
+    arr = np.ones((int(arrsize * 1024**2 / np.dtype("float").itemsize), ))
+    normDict = {"arr": arr, "b": "string"}
+    assert sizeOf(normDict, "normDict") > arrsize
+
+def test_scalarparser():
+
+    # Ensure int-likes are properly recognized
+    _scalar_parser(3, ntype="int_like")
+    _scalar_parser(3.0, ntype="int_like")
+    with pytest.raises(ValueError) as valerr:
+        _scalar_parser(3.14, ntype="int_like")
+        assert "`varname` has to be an integer between -inf and inf, not 3.14" in str(valerr.value)
+
+    # Ensure limits are inclusive and respected
+    _scalar_parser(3, lims=[-4, 4])
+    _scalar_parser(3, lims=[3, 3])
+    with pytest.raises(ValueError) as valerr:
+        _scalar_parser(3, lims=[0, 2])
+        assert "`varname` has to be an integer between 0 and 2, not 3" in str(valerr.value)
+
+    # Anything not-int-like should parse fine too
+    _scalar_parser(3, ntype="something", lims=[3, 3])
+
+    # Anything not number-like should not
+    with pytest.raises(TypeError) as tperr:
+        _scalar_parser("notAnumber")
+        assert "`varname` has to be a scalar, not <class 'str'>" in str(tperr.value)
+
+def test_logger():
+
+    # Basic input parsing
+    with pytest.raises(TypeError):
+        prepare_log(logname=3)
+    with pytest.raises(TypeError):
+        prepare_log(verbose="invalid")
+
+    # Create tmp directory for logfile
+    tempDir = os.path.join(os.path.abspath(os.path.expanduser("~")), "acme_tmp")
+    os.makedirs(tempDir, exist_ok=True)
+    customLog = os.path.join(tempDir, "logger_test.txt")
+
+    # Ensure basic logging functionality
+    prepare_log(logname="custom", logfile=customLog)
+    log = logging.getLogger("custom")
+    logMsg = "this was test_logger"
+    dbgMsg = "this is invisible"
+    log.info(logMsg)
+    log.debug(dbgMsg)
+    memHandlers = [h for h in log.handlers if isinstance(h, handlers.MemoryHandler)]
+    assert len(memHandlers) == 1
+    memHandler = memHandlers[0]
+    memHandler.flush()  # important: flush memory to write its contents to file
+    assert memHandler.target is not None
+    assert memHandler.target.baseFilename == customLog
+    with open(customLog, "r", encoding="utf8") as fl:
+        logTxt = fl.read()
+    assert logMsg in logTxt
+    assert dbgMsg not in logTxt
+
+    # Ensure logger creation works w/existing file
+    prepare_log(logname="custom", logfile=customLog)
+    logMsg2 = "another test_logger"
+    log.info(logMsg2)
+    memHandlers = [h for h in log.handlers if isinstance(h, handlers.MemoryHandler)]
+    assert len(memHandlers) == 1
+    memHandler = memHandlers[0]
+    memHandler.flush()  # important: flush memory to write its contents to file
+    assert memHandler.target is not None
+    assert memHandler.target.baseFilename == customLog
+    with open(customLog, "r", encoding="utf8") as fl:
+        logTxt = fl.read()
+    assert logMsg2 in logTxt
+
+    # Ensure chosen log levels are respected
+    prepare_log(logname="custom", logfile=customLog, verbose=False)
+    logWrn = "this must show up"
+    logMsg3 = "this should not show up"
+    log.warning(logWrn)
+    log.info(logMsg3)
+    memHandlers = [h for h in log.handlers if isinstance(h, handlers.MemoryHandler)]
+    assert len(memHandlers) == 1
+    memHandler = memHandlers[0]
+    memHandler.flush()  # important: flush memory to write its contents to file
+    assert memHandler.target is not None
+    assert memHandler.target.baseFilename == customLog
+    with open(customLog, "r", encoding="utf8") as fl:
+        logTxt = fl.read()
+    assert logWrn in logTxt
+    assert logMsg3 not in logTxt
+
+    # Clean up
+    os.unlink(customLog)
+    shutil.rmtree(tempDir, ignore_errors=True)
diff --git a/acme/tests/test_pmap.py b/acme/tests/test_pmap.py
index b11281b..a0512ef 100644
--- a/acme/tests/test_pmap.py
+++ b/acme/tests/test_pmap.py
@@ -27,22 +27,42 @@
 from logging import handlers
 from glob import glob
 from scipy import signal
+from numpy.typing import NDArray
+from typing import Any, Optional, Union, Dict
 
 # Import main actors here
-from acme import ParallelMap, cluster_cleanup, esi_cluster_setup
-from conftest import skip_if_not_linux, useSLURM, onESI, defaultQ
+from acme import ParallelMap, ACMEdaemon, cluster_cleanup, esi_cluster_setup
+from conftest import skip_if_not_linux, useSLURM, onESI, onx86, defaultQ
+
+# Define custom types
+realArrayLike = Union[float, NDArray[np.float64]]
+realArray = NDArray[np.float64]
 
 # Functions that act as stand-ins for user-funcs
-def simple_func(x, y, z=3):
+def simple_func(
+        x: float,
+        y: float,
+        z: float = 3) -> float:
     return (x + y) * z
 
-def medium_func(x, y, z=3, w=np.ones((3, 3))):
+def medium_func(
+        x: realArray,
+        y: realArrayLike,
+        z: realArrayLike = 3,
+        w: NDArray = np.ones((3, 3))) -> realArrayLike:
     return (sum(x) + y) * z * w.max()
 
-def hard_func(x, y, z=3, w=np.zeros((3, 1)), **kwargs):
+def hard_func(
+        x: realArray,
+        y: realArrayLike,
+        z: realArrayLike = 3,
+        w: realArray = np.zeros((3, 1)),
+        **kwargs: Optional[Any]) -> tuple[realArrayLike, realArray]:
     return sum(x) + y,  z * w
 
-def lowpass_simple(h5name, channel_no):
+def lowpass_simple(
+        h5name: str,
+        channel_no: int) -> realArray:
     with h5py.File(h5name, "r") as h5f:
         channel = h5f["data"][:, channel_no]
         b = h5f["data"].attrs["b"]
@@ -50,7 +70,9 @@ def lowpass_simple(h5name, channel_no):
     res = signal.filtfilt(b, a, channel, padlen=200)
     return res
 
-def lowpass_medium(h5name, channel_no):
+def lowpass_medium(
+        h5name: str,
+        channel_no: int) -> tuple[realArray, int, float, float]:
     with h5py.File(h5name, "r") as h5f:
         channel = h5f["data"][:, channel_no]
         b = h5f["data"].attrs["b"]
@@ -58,7 +80,15 @@ def lowpass_medium(h5name, channel_no):
     res = signal.filtfilt(b, a, channel, padlen=200)
     return res, channel_no, b, a
 
-def lowpass_hard(arr_like, b, a, res_dir, res_base="lowpass_hard_", dset_name="custom_dset_name", padlen=200, taskID=None):
+def lowpass_hard(
+        arr_like: realArray,
+        b: float,
+        a: float,
+        res_dir: str,
+        res_base: str = "lowpass_hard_",
+        dset_name: str = "custom_dset_name",
+        padlen: int = 200,
+        taskID: Optional[int] = None) -> None:
     channel = arr_like[:, taskID]
     res = signal.filtfilt(b, a, channel, padlen=padlen)
     h5name = os.path.join(res_dir, res_base +"{}.h5".format(taskID))
@@ -66,14 +96,24 @@ def lowpass_hard(arr_like, b, a, res_dir, res_base="lowpass_hard_", dset_name="c
         h5f.create_dataset(dset_name, data=res)
     return
 
-def pickle_func(arr, b, a, channel_no, sabotage_hdf5=False):
+def pickle_func(
+        arr: realArray,
+        b: float,
+        a: float,
+        channel_no: int,
+        sabotage_hdf5: bool = False) -> Union[Dict[str, float], realArray]:
     res = signal.filtfilt(b, a, arr[:, channel_no], padlen=200)
     if sabotage_hdf5:
         if channel_no % 2 == 0:
             return {"b" : b}
     return res
 
-def memtest_func(x, y, z=3, arrsize=2, sleeper=300):
+def memtest_func(
+        x: float,
+        y: float,
+        z: float = 3,
+        arrsize: float = 2,
+        sleeper: int = 300) -> float:
     fSize = np.dtype("float").itemsize
     time.sleep(2)
     arr = np.ones((int(arrsize * 1024**3 / fSize), ))   # `arrsize` denotes array size in GB
@@ -116,6 +156,7 @@ def _prep_data(self, tmpName):
         tempDir = os.path.join(os.path.abspath(os.path.expanduser("~")), tmpName)
         if useSLURM:
             tempDir = "/cs/home/{}/{}".format(getpass.getuser(), tmpName)
+        shutil.rmtree(tempDir, ignore_errors=True)
         os.makedirs(tempDir, exist_ok=True)
         sigName = os.path.join(tempDir, "sigdata.h5")
         origName = os.path.join(tempDir, "origdata.h5")
@@ -192,6 +233,74 @@ def test_init(self, testclient=None):
             ParallelMap(hard_func, [2, 4, 6, 8], [2, 2], w=np.ones((8, 1)), n_inputs=8, setup_interactive=False)
             assert "No object has required length of 8 matching `n_inputs`" in str(valerr.value)
 
+        # Check if other parameters  are parsed correctly
+        with pytest.raises(TypeError):
+            ParallelMap("invalid")
+        with pytest.raises(ValueError):
+            ParallelMap(simple_func, [2, 4, 6, 8], 4, n_inputs="not-auto")
+        with pytest.raises(ValueError):
+            ParallelMap(simple_func, [2, 4, 6, 8], 4, n_inputs=-3)
+        with pytest.raises(ValueError):
+            ParallelMap(simple_func, [2, 4, 6, 8], 4, n_inputs=3.6)
+        with pytest.raises(TypeError):
+            ParallelMap(simple_func, [2, 4, 6, 8], 4, n_inputs={})
+        with pytest.raises(TypeError):
+            ParallelMap(simple_func, [2, 4, 6, 8], 4, partition=defaultQ, write_worker_results="invalid")
+        with pytest.raises(TypeError):
+            ParallelMap(simple_func, [2, 4, 6, 8], 4, partition=defaultQ, single_file="invalid")
+        with pytest.raises(TypeError):
+            ParallelMap(simple_func, [2, 4, 6, 8], 4, partition=defaultQ, write_pickle="invalid")
+        with pytest.raises(TypeError):
+            ParallelMap(simple_func, [2, 4, 6, 8], 4, partition=defaultQ, logfile=3)
+        with pytest.raises(IOError):
+            ParallelMap(simple_func, [2, 4, 6, 8], 4, partition=defaultQ, logfile=os.path.dirname(os.path.realpath(__file__)))
+        with pytest.raises(TypeError):
+            ParallelMap(simple_func, [2, 4, 6, 8], 4, partition=defaultQ, output_dir=2)
+        with pytest.raises(OSError):
+            ParallelMap(simple_func, [2, 4, 6, 8], 4, partition=defaultQ, output_dir="/path/to/nowhere")
+        with pytest.raises(TypeError):
+            ParallelMap(simple_func, [2, 4, 6, 8], 4, partition=defaultQ, stop_client=3)
+        with pytest.raises(ValueError):
+            ParallelMap(simple_func, [2, 4, 6, 8], 4, partition=defaultQ, stop_client="not-auto")
+
+        # Check parameters that are only parsed if a new client has been started
+        if testclient is None:
+            cluster_cleanup()
+            if useSLURM:
+                with pytest.raises(TypeError):
+                    ParallelMap(simple_func, [2, 4, 6, 8], 4, partition=3)
+            with pytest.raises(ValueError):
+                ParallelMap(simple_func, [2, 4, 6, 8], 4, partition=defaultQ, n_workers="invalid")
+
+            # start a client for real
+            cluster_cleanup()
+            pmap = ParallelMap(simple_func,
+                               [2, 4, 6, 8],
+                               4,
+                               partition=defaultQ,
+                               n_workers=1,
+                               setup_interactive=False)
+            outDirs.append(pmap.daemon.out_dir)
+
+            with pytest.raises(TypeError):
+                pmap.daemon.compute(debug="invalid")
+
+            # Kill our single worker and ensure ACME takes note
+            client = pmap.daemon.client
+            client.retire_workers(list(client.scheduler_info()['workers']), close_workers=True)
+            with pytest.raises(RuntimeError) as rerr:
+                pmap.daemon.compute()
+                assert "no active workers found" in str(rerr.value)
+
+            # Annihilate client slot and ensure pmap does not do anything funky
+            pmap.daemon.client = None
+            assert pmap.compute() is None
+
+        # Finally, test ACMEdaemon only accepts `ParallelMap` objects
+        with pytest.raises(TypeError) as tperr:
+            ACMEdaemon("invalid")
+            assert "`pmap` has to be a `ParallelMap` instance, not <class 'str'>" in str(tperr.value)
+
         # Clean up testing folder and any running clients
         if testclient is None:
             cluster_cleanup()
@@ -200,11 +309,100 @@ def test_init(self, testclient=None):
 
         return testclient
 
+    # test if examples shown on GitHub actually work
+    def test_github_examples(self):
+
+        # Collected auto-generated output directories in list for later cleanup
+        outDirs = []
+
+        def f(x, y, z=3):
+            return (x + y) * z
+        expected = list(map(f, [2, 4, 6, 8], [4, 4, 4, 4]))
+
+        with ParallelMap(f, [2, 4, 6, 8], 4) as pmap:
+            filenames = pmap.compute()
+        outDirs.append(pmap.out_dir)
+
+        out = np.zeros((4,))
+        with h5py.File(pmap.results_container, "r") as h5f:
+            for k, key in enumerate(h5f.keys()):
+                out[k] = h5f[key]["result_0"][()]
+        assert np.array_equal(out, expected)
+
+        with ParallelMap(f, [2, 4, 6, 8], 4, result_shape=(None,)) as pmap:
+            pmap.compute()
+        outDirs.append(pmap.out_dir)
+        with h5py.File(pmap.results_container, "r") as h5f:
+            out == h5f["result_0"][()] # returns a NumPy array of shape (4,)
+        assert np.array_equal(out, expected)
+
+        with ParallelMap(f, [2, 4, 6, 8], 4, write_worker_results=False) as pmap:
+            result = pmap.compute() # returns a 4-element list
+        assert result == expected
+        with ParallelMap(f, [2, 4, 6, 8], 4, write_worker_results=False, result_shape=(None,)) as pmap:
+            result = pmap.compute() # returns a NumPy array of shape (4,)
+        assert np.array_equal(out, expected)
+
+        def f(x, y, z=3, w=np.zeros((3, 1)), **kwargs):
+            return (sum(x) + y) * z * w.max()
+        expected = list(map(f, 2*[[2, 4, 6, 8]], [2, 2], np.array([1, 2]), 2*[np.ones((8, 1))]))
+
+        pmap = ParallelMap(f, [2, 4, 6, 8], [2, 2], z=np.array([1, 2]), w=np.ones((8, 1)), n_inputs=2)
+        with pmap as p:
+            p.compute()
+        outDirs.append(pmap.daemon.out_dir)
+        out = []
+        with h5py.File(pmap.daemon.results_container, "r") as h5f:
+            for k, key in enumerate(h5f.keys()):
+                out.append(h5f[key]["result_0"][()][0])
+        assert np.array_equal(out, expected)
+
+        def f(x, y, z=3, w=np.zeros((3, 1)), **kwargs):
+            return (sum(x) + y) * z * w.max()
+
+        def g(x, y, z=3, w=np.zeros((3, 1)), **kwargs):
+            return (max(x) + y) * z * w.sum()
+
+        n_workers = 2
+        x = [2, 4, 6, 8]
+        y = np.random.rand(n_workers)
+        z = range(n_workers)
+        w = np.ones((8, 1))
+
+        client = esi_cluster_setup(partition=defaultQ, n_workers=n_workers, interactive=False)
+
+        expected = list(map(f, n_workers*[x], y, list(z), n_workers*[w]))
+        pmap = ParallelMap(f, x, y, z=z, w=w, n_inputs=n_workers)
+        with pmap as p:
+            p.compute()
+        outDirs.append(pmap.daemon.out_dir)
+        out = []
+        with h5py.File(pmap.daemon.results_container, "r") as h5f:
+            for k, key in enumerate(h5f.keys()):
+                out.append(h5f[key]["result_0"][()][0])
+        assert np.array_equal(out, expected)
+
+        expected = list(map(g, n_workers*[x], y, list(z), n_workers*[w]))
+        pmap = ParallelMap(g, x, y, z=z, w=w, n_inputs=n_workers)
+        with pmap as p:
+            p.compute()
+        outDirs.append(pmap.daemon.out_dir)
+        out = []
+        with h5py.File(pmap.daemon.results_container, "r") as h5f:
+            for k, key in enumerate(h5f.keys()):
+                out.append(h5f[key]["result_0"][()][0])
+        assert np.array_equal(out, expected)
+
+        # Clean up testing folder and shut down allocated client
+        for folder in outDirs:
+            shutil.rmtree(folder, ignore_errors=True)
+        cluster_cleanup(client)
+
     # Functionality tests: perform channel-concurrent low-pass filtering
     def test_simple_filter(self, testclient=None):
 
         # Prepare data containers
-        tempDir, sigName = self._prep_data("acme_tmp")
+        tempDir, sigName = self._prep_data(f"acme_tmp_{platform.machine()}")
 
         # Collect auto-generated output directories in list for later cleanup
         outDirs = []
@@ -338,7 +536,8 @@ def test_simple_filter(self, testclient=None):
         # Simulate user-defined results-directory not auto-populated by ACME
         tempDir2 = os.path.join(os.path.abspath(os.path.expanduser("~")), "acme_tmp_lowpass_hard")
         if useSLURM:
-            tempDir2 = "/cs/home/{}/acme_tmp_lowpass_hard".format(getpass.getuser())
+            tempDir2 = f"/cs/home/{getpass.getuser()}/acme_tmp_lowpass_hard_{platform.machine()}"
+        shutil.rmtree(tempDir2, ignore_errors=True)
         os.makedirs(tempDir2, exist_ok=True)
 
         # Same task, different function: simulate user-defined saving scheme and "weird" inputs
@@ -392,7 +591,7 @@ def test_simple_filter(self, testclient=None):
             assert "/cs/home/" in logFile
         else:
             assert os.path.dirname(os.path.realpath(__file__)) in logFile
-        with open(logFile, "r") as fl:
+        with open(logFile, "r", encoding="utf8") as fl:
             assert len(fl.readlines()) > 1
 
         # Ensure client has not been killed; perform post-hoc check of default SLURM settings
@@ -402,10 +601,11 @@ def test_simple_filter(self, testclient=None):
             assert pmap.n_calls == pmap.n_workers
             assert len(client.cluster.workers) == pmap.n_workers
             partition = client.cluster.job_header.split("-p ")[1].split("\n")[0]
-            assert "8GB" in partition
-            memory = np.unique([w["memory_limit"] for w in client.cluster.scheduler_info["workers"].values()])
-            assert memory.size == 1
-            assert round(memory[0] / 1000**3) == [int(s) for s in partition if s.isdigit()][0]
+            if onx86:
+                assert "8GB" in partition
+                memory = np.unique([w["memory_limit"] for w in client.cluster.scheduler_info["workers"].values()])
+                assert memory.size == 1
+                assert round(memory[0] / 1000**3) == [int(s) for s in partition if s.isdigit()][0]
 
         # Wait a sec (literally) for dask to collect its bearings (after the
         # `get_client` above) before proceeding
@@ -424,7 +624,7 @@ def test_simple_filter(self, testclient=None):
             pmap.compute()
         outDirs.append(pmap.out_dir)
         assert os.path.isfile(customLog)
-        with open(customLog, "r") as fl:
+        with open(customLog, "r", encoding="utf8") as fl:
             assert len(fl.readlines()) > 1
 
         # Ensure only single log file `customLog` is used
@@ -440,14 +640,102 @@ def test_simple_filter(self, testclient=None):
         # Wait a sec (literally) to give dask enough time to close the client
         time.sleep(1.0)
 
+        # Request a log-file but don't save results
+        with ParallelMap(lowpass_simple,
+                         sigName,
+                         range(self.nChannels),
+                         logfile=True,
+                         write_worker_results=False,
+                         n_workers=1,
+                         partition=defaultQ,
+                         setup_interactive=False) as pmap:
+            pmap.compute()
+        assert pmap.out_dir is None
+        log = logging.getLogger("ACME")
+        logFileList = [handler.target.baseFilename for handler in log.handlers if isinstance(handler, handlers.MemoryHandler)]
+        assert len(logFileList) == 1
+        assert os.path.dirname(os.path.realpath(__file__)) in logFileList[0]
+        os.unlink(logFileList[0])
+
+        # Ensure ACME warns if arguments increase its "sanity" threshold
+        # (lowered here to not overwhelm CI runners)
+        mAS = ParallelMap._maxArgSize
+        ParallelMap._maxArgSize = 1   # in MB
+        pmap = ParallelMap(simple_func,
+                           [np.ones((1000, 1000)), 4, 6, 8],
+                           4,
+                           partition=defaultQ,
+                           n_workers=1,
+                           logfile=True,
+                           setup_interactive=False)
+        log = logging.getLogger("ACME")
+        memHandlers = [h for h in log.handlers if isinstance(h, handlers.MemoryHandler)]
+        assert len(memHandlers) == 1
+        memHandler = memHandlers[0]
+        memHandler.flush()  # important: flush memory to write its contents to file
+        assert memHandler.target is not None
+        thisLogFile = memHandler.target.baseFilename
+        with open(thisLogFile, "r", encoding="utf8") as fl:
+            logTxt = fl.read()
+        assert "exceeds recommended limit of 1 MB" in logTxt
+        os.unlink(thisLogFile)
+
+        # Same with kwargs
+        pmap = ParallelMap(simple_func,
+                           [2, 4, 6, 8],
+                           4,
+                           z=np.ones((1000, 1000)),
+                           partition=defaultQ,
+                           n_workers=1,
+                           logfile=True,
+                           setup_interactive=False)
+        log = logging.getLogger("ACME")
+        log = logging.getLogger("ACME")
+        memHandlers = [h for h in log.handlers if isinstance(h, handlers.MemoryHandler)]
+        assert len(memHandlers) == 1
+        memHandler = memHandlers[0]
+        memHandler.flush()  # important: flush memory to write its contents to file
+        assert memHandler.target is not None
+        thisLogFile = memHandler.target.baseFilename
+        with open(thisLogFile, "r", encoding="utf8") as fl:
+            logTxt = fl.read()
+        assert "exceeds recommended limit of 1 MB" in logTxt
+        os.unlink(thisLogFile)
+
+        # Reset maxArgSize and continue
+        ParallelMap._maxArgSize = mAS
+
+        # Ensure warning is issued if single-file saving is requested but result writing is turned off
+        with ParallelMap(lowpass_simple,
+                         sigName,
+                         range(self.nChannels),
+                         write_worker_results=False,
+                         single_file=True,
+                         partition=defaultQ,
+                         logfile=True,
+                         n_workers=1,
+                         setup_interactive=False) as pmap:
+            resInMem2 = pmap.compute()
+        assert pmap.out_dir is None
+        assert np.all(resInMem2) == np.all(resInMem)
+        log = logging.getLogger("ACME")
+        logFileList = [handler.target.baseFilename for handler in log.handlers if isinstance(handler, handlers.MemoryHandler)]
+        assert len(logFileList) == 1
+        with open(logFileList[0], "r", encoding="utf8") as fl:
+            logTxt = fl.read()
+        assert "Generating a single output file only possible if `write_worker_results` is `True`" in logTxt
+        os.unlink(logFileList[0])
+
+        if testclient is None:
+            cluster_cleanup(pmap.client)
+
         # Underbook SLURM (more calls than workers)
-        partition = "8GBXS"
         n_workers = int(self.nChannels / 2)
         mem_per_worker = "2GB"
         with ParallelMap(lowpass_simple,
                          sigName,
                          range(self.nChannels),
-                         partition=partition,
+                         partition=defaultQ,
                          n_workers=n_workers,
                          mem_per_worker=mem_per_worker,
                          stop_client=False,
@@ -462,7 +750,7 @@ def test_simple_filter(self, testclient=None):
             assert pmap.n_workers == n_workers
             assert len(client.cluster.workers) == pmap.n_workers
             actualPartition = client.cluster.job_header.split("-p ")[1].split("\n")[0]
-            assert actualPartition == partition
+            assert actualPartition == defaultQ
             memory = np.unique([w["memory_limit"] for w in client.cluster.scheduler_info["workers"].values()])
             assert memory.size == 1
             assert round(memory[0] / 1000**3) == int(mem_per_worker.replace("GB", ""))
@@ -474,13 +762,12 @@ def test_simple_filter(self, testclient=None):
                 dd.get_client()
 
         # Overbook SLURM (more workers than calls)
-        partition = "8GBXS"
         n_workers = self.nChannels + 2
         mem_per_worker = "3000MB"
         with ParallelMap(lowpass_simple,
                          sigName,
                          range(self.nChannels),
-                         partition=partition,
+                         partition=defaultQ,
                          n_workers=n_workers,
                          mem_per_worker=mem_per_worker,
                          stop_client=False,
@@ -495,7 +782,7 @@ def test_simple_filter(self, testclient=None):
             assert pmap.n_workers == n_workers
             assert len(client.cluster.workers) == pmap.n_workers
             actualPartition = client.cluster.job_header.split("-p ")[1].split("\n")[0]
-            assert actualPartition == partition
+            assert actualPartition == defaultQ
             memory = np.unique([w["memory_limit"] for w in client.cluster.scheduler_info["workers"].values()])
             assert memory.size == 1
             assert round(memory[0] / 1000**3) * 1000 == int(mem_per_worker.replace("MB", ""))
@@ -817,7 +1104,7 @@ def test_outshape(self, testclient=None):
                          single_file=False,
                          partition=defaultQ,
                          setup_interactive=False) as pmap:
-             pmap.compute()
+            pmap.compute()
         outDirs.append(pmap.out_dir)
 
         # Save results for later
@@ -954,6 +1241,26 @@ def test_pickling(self, testclient=None):
             mixedResults = pmap.compute()
         outDirs.append(pmap.out_dir)
 
+        # Ensure warning is issued if pickling is requested but result writing is turned off
+        with ParallelMap(simple_func,
+                         [2, 4, 6, 8],
+                         4,
+                         partition=defaultQ,
+                         write_worker_results=False,
+                         write_pickle=True,
+                         logfile=True,
+                         n_workers=1,
+                         setup_interactive=False) as pmap:
+                results = pmap.compute()
+        assert pmap.out_dir is None
+        assert results == list(map(simple_func, [2, 4, 6, 8], [4, 4, 4, 4]))
+        log = logging.getLogger("ACME")
+        logFileList = [handler.target.baseFilename for handler in log.handlers if isinstance(handler, handlers.MemoryHandler)]
+        assert len(logFileList) == 1
+        with open(logFileList[0], "r", encoding="utf8") as fl:
+            logTxt = fl.read()
+        assert "Pickling of results only possible if `write_worker_results` is `True`" in logTxt
+
         # Collection container should have been auto-removed
         resultsContainer = os.path.basename(mixedResults[0])
         resultsContainer = os.path.join(os.path.dirname(mixedResults[0]),
@@ -1032,7 +1339,8 @@ def test_pickling(self, testclient=None):
     def test_cancel(self):
 
         # Setup temp-directory layout for subprocess-scripts and prepare interpreters
-        tempDir = os.path.join(os.path.abspath(os.path.expanduser("~")), "acme_tmp")
+        tempDir = os.path.join(os.path.abspath(os.path.expanduser("~")), f"acme_tmp_{platform.machine()}")
+        shutil.rmtree(tempDir, ignore_errors=True)
         os.makedirs(tempDir, exist_ok=True)
         pshells = [os.path.join(os.path.split(sys.executable)[0], pyExec) for pyExec in ["python", "ipython"]]
 
@@ -1046,10 +1354,10 @@ def test_cancel(self):
             "   return\n" +\
             "if __name__ == '__main__':\n" +\
             "   cluster_cleanup() \n" +\
-            "   with ParallelMap(long_running, [None]*2, setup_interactive=False, partition='8GBXS', write_worker_results=False) as pmap: \n" +\
+            f"   with ParallelMap(long_running, [None]*2, setup_interactive=False, partition='{defaultQ}', write_worker_results=False) as pmap: \n" +\
             "       pmap.compute()\n" +\
             "   print('ALL DONE')\n"
-        with open(scriptName, "w") as f:
+        with open(scriptName, "w", encoding="utf8") as f:
             f.write(scriptContents)
 
         # Execute the above script both in Python and iPython to ensure global functionality
@@ -1091,11 +1399,11 @@ def test_cancel(self):
             "   time.sleep(10)\n" +\
             "   return\n" +\
             "if __name__ == '__main__':\n" +\
-            "   client = esi_cluster_setup(partition='8GBDEV',n_workers=1, interactive=False)\n" +\
+            f"   client = esi_cluster_setup(partition='{defaultQ}',n_workers=1, interactive=False)\n" +\
             "   with ParallelMap(long_running, [None]*2, setup_interactive=False, write_worker_results=False, verbose=True) as pmap: \n" +\
             "       pmap.compute()\n" +\
             "   print('ALL DONE')\n"
-        with open(scriptName, "w") as f:
+        with open(scriptName, "w", encoding="utf8") as f:
             f.write(scriptContents)
 
         # Test script functionality in both Python and iPython
@@ -1125,9 +1433,9 @@ def test_cancel(self):
             "from acme import esi_cluster_setup\n" +\
             "import time\n" +\
             "if __name__ == '__main__':\n" +\
-            "   esi_cluster_setup(partition='8GBDEV',n_workers=1, interactive=False)\n" +\
+            f"   esi_cluster_setup(partition='{defaultQ}',n_workers=1, interactive=False)\n" +\
             "   time.sleep(60)\n"
-        with open(scriptName, "w") as f:
+        with open(scriptName, "w", encoding="utf8") as f:
             f.write(scriptContents)
         proc = subprocess.Popen("stdbuf -o0 " + sys.executable + " " + scriptName,
                                 shell=True, start_new_session=True,
@@ -1158,18 +1466,30 @@ def test_dryrun(self, monkeypatch):
         # directory must be cleaned up
         monkeypatch.setattr("builtins.input", lambda _ : "n")
         pmap = ParallelMap(simple_func, [2, 4, 6, 8], 4, setup_interactive=True, dryrun=True)
+        time.sleep(1.0)
 
         # Ensure auto-generated output dir has been successfully removed
         outDir = pmap.daemon.out_dir
         assert os.path.exists(outDir) is False
 
+        # Now go through with the dry-run
+        monkeypatch.setattr("builtins.input", lambda _ : "y")
+        with ParallelMap(simple_func,
+                         [2, 4, 6, 8],
+                         4,
+                         setup_interactive=True,
+                         dryrun=True) as pmap:
+            pmap.compute()
+        shutil.rmtree(pmap.out_dir, ignore_errors=True)
+
     def test_memest(self):
 
         # First kill any existing client (otherwise profiling is not happening)
         cluster_cleanup()
 
         # Create tmp directory for logfile
-        tempDir = os.path.join(os.path.abspath(os.path.expanduser("~")), "acme_tmp")
+        tempDir = os.path.join(os.path.abspath(os.path.expanduser("~")), f"acme_tmp_{platform.machine()}")
+        shutil.rmtree(tempDir, ignore_errors=True)
         os.makedirs(tempDir, exist_ok=True)
         customLog = os.path.join(tempDir, "mem_log.txt")
         outDirs = []
@@ -1178,7 +1498,7 @@ def test_memest(self):
         # mem estimates accordingly
         arrsize = 2
         estMem = 3
-        if platform.machine() == "ppc64le":
+        if not useSLURM and platform.machine() == "ppc64le":
             arrsize = 0.5
             estMem = 1
 
@@ -1224,7 +1544,7 @@ def test_memest(self):
 
         # If running on the ESI cluster, ensure the correct partition has been picked
         if onESI and useSLURM:
-            assert "Picked partition 8GBXS based on estimated memory consumption of 3 GB" in logTxt
+            assert f"Picked partition {defaultQ} based on estimated memory consumption of 3 GB" in logTxt
 
         # Profiling completed full run of `memtest_func`: ensure any auto-created
         # output HDF5 files were removed
@@ -1260,7 +1580,7 @@ def test_memest(self):
                            sleeper=300,
                            arrsize=arrsize,
                            logfile=customLog2,
-                           setup_timeout=10,
+                           setup_timeout=30,
                            setup_interactive=False)
 
         # Again, fire off `estimate_memuse` manually if tests are run locally
@@ -1289,7 +1609,7 @@ def test_memest(self):
 
         # If running on the ESI cluster, ensure the correct partition has been picked (again)
         if onESI and useSLURM:
-            assert "Picked partition 8GBXS based on estimated memory consumption of 3 GB" in logTxt
+            assert f"Picked partition {defaultQ} based on estimated memory consumption of 3 GB" in logTxt
 
         # Profiling should not have generated any output
         outDirs.append(pmap.daemon.out_dir)
@@ -1322,13 +1642,20 @@ def test_memest(self):
 
             # Simulate `ParallelMap(partition="auto",...)` call by invoking `esi_cluster_setup`
             # with `mem_per_worker='esstimate_memuse:XY'`
+            memUse = "estimate_memuse:12"
             client = esi_cluster_setup(partition="auto",
-                                       mem_per_worker="estimate_memuse:12",
+                                       mem_per_worker=memUse,
                                        n_workers=1,
                                        interactive=False)
 
-            # Ensure the right partition was picked (16GBXY, not 8GBXY)
-            assert "16GB" in client.cluster.job_header.split("-p ")[1].split("\n")[0]
+            # Ensure the right partition was picked (16GBXY, not 8GBXY on x86)
+            if onx86:
+                assert "16GB" in client.cluster.job_header.split("-p ")[1].split("\n")[0]
+            else:
+                memory = np.unique([w["memory_limit"] for w in client.cluster.scheduler_info["workers"].values()])
+                assert memory.size == 1
+                assert round(memory[0] / 1000**3) == int(memUse.split(":")[1])
+
             cluster_cleanup(client)
 
             # Full run (finally) w/10 workers, 5 of em get mem-profiled
@@ -1346,7 +1673,7 @@ def test_memest(self):
             with open(customLog3, "r", encoding="utf8") as f:
                 logTxt = f.read()
             assert "Estimated memory consumption across 5 runs" in logTxt
-            assert "Picked partition 8GBXS" in logTxt
+            assert f"Picked partition {defaultQ}" in logTxt
             outDirs.append(pmap.out_dir)
 
         # Clean up
@@ -1377,11 +1704,11 @@ def test_backcompat(self):
 
         # Ensure a deprecation warning was issued
         log = logging.getLogger("ACME")
-        for handler in log.handlers:
-            if isinstance(handler, logging.FileHandler):
-                with open(handler.baseFilename, "r") as fl:
-                    logTxt = fl.read()
-                assert "DEPRECATED" in logTxt
+        logFileList = [handler.target.baseFilename for handler in log.handlers if isinstance(handler, handlers.MemoryHandler)]
+        assert len(logFileList) == 1
+        with open(logFileList[0], "r", encoding="utf8") as fl:
+            logTxt = fl.read()
+        assert "DEPRECATED" in logTxt
 
         if useSLURM:
 
@@ -1416,6 +1743,10 @@ def test_backcompat(self):
     # test esi-cluster-setup called separately before pmap
     def test_existing_cluster(self):
 
+        # Do not execute on GitHub runner
+        if os.environ.get("GITHUB_ACTIONS") or os.environ.get("INTOX"):
+            return
+
         # Test custom SLURM cluster setup
         if useSLURM:
 
@@ -1428,18 +1759,20 @@ def test_existing_cluster(self):
             assert "--output={}".format(slurmOut) in client.cluster.job_header
 
         else:
-            client = esi_cluster_setup(interactive=False)
+            client = esi_cluster_setup(partition="auto", interactive=False)
+        print("Allocated client")
 
         # Re-run tests with pre-allocated client (except for those in `skipTests`); ensure
         # client "survives" multiple independent test runs and is not accidentally closed
         skipTests = ["test_existing_cluster", "test_cancel", "test_dryrun",
-                     "test_memest", "test_backcompat", "_prep_data"]
+                     "test_memest", "test_backcompat", "test_github_examples",
+                     "_prep_data"]
         all_tests = [attr for attr in self.__dir__()
                      if (inspect.ismethod(getattr(self, attr)) and attr not in skipTests)]
         for test in all_tests:
+            print("Running test ", test)
             clnt = getattr(self, test)(testclient=client)
             assert clnt == client
-        client.close()
-        client.cluster.close()
+        cluster_cleanup()
         if useSLURM:
             shutil.rmtree(slurmOut, ignore_errors=True)