Repository: DiamondLightSource/httomo (language: python)

Issue:
Sweep functionality
As this stands as a high priority to complete, I've been thinking how this can be done on a higher level first. 

Here are some general constraints, that we probably should impose to make things simpler: 
- Only one parameter to optimise at the time
- Input is always a small `preview` set of 5-7 slices. I'd go with >3 slices as it with 3d methods in place, we would like to have some adjacent neighbouring information vertically. The user can specify the previewed set but we always need to take middle 5 slices of that set. For the whole volume it will be the middle chunk. 
- The output does NOT go into the hdf5 file automatically. I'll work to resolve #283 so it is a user-controlled thing. Probably if the user enables saving, then we should warn and switch that off?  The 4D dataset could be an option though, but might not necessarily needed, if the bellow option is enabled. 
- The output saves the series of tiffs with respect to each parameter value that is being optimised in the `sweep` selection. Only the mid-slice of the previewed 5- slices set is being saved in the tiff file.  

Implementation notes: 
- we need a way how to loop the series of methods. Possibly a section should be created starting with the method which parameters are being optimised? 
- save_to_images probably can be auto-substituted but as we need just one file (mid-slice) to be saved may be we could come up with a slightly modified wrapper? The parameter value should be in the name of the tiff file created. 




Discussion / hints:

Fix patch:
diff --git a/docs/source/howto/httomo_features.rst b/docs/source/howto/httomo_features.rst
index 0f596238e..dd49427bb 100644
--- a/docs/source/howto/httomo_features.rst
+++ b/docs/source/howto/httomo_features.rst
@@ -6,6 +6,6 @@ HTTomo Features
 
    httomo_features/previewing
    httomo_features/centering
-   httomo_features/parameter_tuning
+   httomo_features/parameter_sweeping
 
 
diff --git a/docs/source/howto/httomo_features/centering.rst b/docs/source/howto/httomo_features/centering.rst
index dabae8be8..30ee72e98 100644
--- a/docs/source/howto/httomo_features/centering.rst
+++ b/docs/source/howto/httomo_features/centering.rst
@@ -93,7 +93,7 @@ Manual Centering
 =================
 
 Unfortunately, there could be various cases when :ref:`centering_auto` fails, e.g., the projection data is corrupted, incomplete, the object is outside the field of view of the detector, and possibly other issues.
-In that case, it is recommended to find the center of rotation manually. :ref:`parameter_tuning` can simplify such search significantly. 
+In that case, it is recommended to find the center of rotation manually. :ref:`parameter_sweeping` can simplify such search significantly.
 
 To enable manual centering, one would need to do the following steps:
 
diff --git a/docs/source/howto/httomo_features/parameter_sweeping.rst b/docs/source/howto/httomo_features/parameter_sweeping.rst
new file mode 100644
index 000000000..d25f6a880
--- /dev/null
+++ b/docs/source/howto/httomo_features/parameter_sweeping.rst
@@ -0,0 +1,120 @@
+.. _parameter_sweeping:
+
+Parameter Sweeping
+^^^^^^^^^^^^^^^^^^
+
+What is it?
+===========
+
+Parameter sweeping refers to providing multiple values for a specific parameter
+of a method, and then running that method on its input data with the different
+values for that parameter.
+
+How would this be useful when processing data?
+==============================================
+
+This feature is typically used when prototyping a process list and it is
+difficult to guess a reasonable value of a method's parameter. There could be
+many reasons for this situation, such as being unfamiliar with the method, or
+working with unfamiliar data, etc.
+
+How are parameter sweeps defined in the process list YAML file?
+===============================================================
+
+There are two ways of specifying the values that a parameter sweep should be
+performed across:
+
+1. Specifying a range of values via start, stop and step values
+
+2. Manually specifying each value
+
+.. note:: A pipeline can only have 1 parameter sweep in it at a time. Any
+   pipelines with more than 1 parameter sweep defined in it will not be
+   executed, and an error message will be displayed.
+
+Specifying a range
+++++++++++++++++++
+
+The first way is done by providing a start, stop and step value. Along with this
+information, a special phrase :code:`!SweepRange` is used to "mark" in the YAML
+that the start, stop and step values are for defining a parameter sweep.
+
+The snippet below is defining a parameter sweep for the :code:`center` parameter
+of a reconstruction method, where the sweep starts at :code:`10`, ends at
+:code:`40` (similar to python slicing, the end value is not included), with steps
+of :code:`10` inbetween:
+
+.. code-block:: yaml
+
+    center: !SweepRange
+      start: 10
+      stop: 50
+      step: 10
+
+Specifying each value
++++++++++++++++++++++
+
+The second way is done by providing a list of values for a parameter, and again
+"marking" the list with a special phrase to denote that this list of values is
+defining a parameter sweep. The phrase in this case is :code:`!Sweep`.
+
+The snippet below is defining a parameter sweep for the :code:`size` parameter
+of a median filter method, where the sweep is across the two values :code:`3`
+and :code:`5`:
+
+.. code-block:: yaml
+
+    size: !Sweep
+      - 3
+      - 5
+
+Example
++++++++
+
+Below, :code:`!Sweep` is used in the context of a fully working minimal
+pipeline to sweep over the :code:`size` parameter of a median filter:
+
+.. literalinclude:: ../../../../tests/samples/pipeline_template_examples/testing/sweep_manual.yaml
+  :language: yaml
+
+How big should the input data be?
+=================================
+
+Due to the goal of parameter sweeps being to provide quick feedback to optimise
+a parameter value, it is typical to run a parameter sweep on a few sinograms,
+rather than the full data.
+
+As such, a parameter sweep run in HTTomo is constrained to run on data previewed
+to contain 7 sinogram slices or less. Meaning, in order to perform a parameter
+sweep in a pipeline, the input data must be cropped to 7 sinogram slices or less
+using the :code:`preview` parameter of the loader (see :ref:`previewing` for
+more details), otherwise the parameter sweep run will not execute and an error
+message will be displayed.
+
+What structure does the output data of a parameter sweep have?
+==============================================================
+
+When a parameter sweep is executed, the output of the method will be the set of
+middle slices from each individual result of the sweep (sinogram slices or recon
+slices), collected along the middle dimension.
+
+For example, suppose:
+
+- the input data is previewed to 3 sinogram slices and has shape
+  :code:`(1801, 3, 2560)`
+
+- a parameter sweep is performed on the :code:`center` parameter of a
+  reconstruction method in the pipeline, across 10 different CoR values
+
+In this case, each execution of the reconstruction method will produce 3 slices,
+as an array of shape :code:`(2560, 3, 2560)`. So, 10 arrays of shape
+:code:`(2560, 3, 2560)` will be produced.
+
+The middle slice from each array of 3 slices will be taken, resulting in 10
+reconstructed slices altogether. These 10 reconstructed slices will then be
+concatenated along the middle dimension and put into a separate array, resulting
+in the final data shape of :code:`(2560, 10, 2560)`.
+
+This output containing 10 slices will then be passed onto the next method in the
+pipeline; for example, a method to save the 10 slices as images for quick
+inspection.
diff --git a/docs/source/howto/httomo_features/parameter_tuning.rst b/docs/source/howto/httomo_features/parameter_tuning.rst
deleted file mode 100644
index 75a4b67c4..000000000
--- a/docs/source/howto/httomo_features/parameter_tuning.rst
+++ /dev/null
@@ -1,25 +0,0 @@
-.. _parameter_tuning:
-
-Parameter Tuning
-^^^^^^^^^^^^^^^^
-
-What is it?
-===========
-
-Parameter tuning (also called "parameter sweeping") refers to providing multiple
-values for a specific parameter of a method, and then running that method on its
-input data with the different values for that parameter.
-
-How would this be useful when processing data?
-==============================================
-
-This feature is typically used when prototyping a process list and it is
-difficult to guess a reasonable value of a method's parameter. There could be
-many reasons for this situation, such as being unfamiliar with the method, or
-working with unfamiliar data, etc.
-
-.. warning::
-    Because of the significant modifications in HTTomo ver.2.0, the parameters tuning feature is not 
-    currently supported. HTTomo developers recognise the importance of it for users and will be 
-    delivered as soon possible with the next release.
-
diff --git a/docs/source/howto/httomo_features/previewing.rst b/docs/source/howto/httomo_features/previewing.rst
index 24b90525c..f6c6ad98b 100644
--- a/docs/source/howto/httomo_features/previewing.rst
+++ b/docs/source/howto/httomo_features/previewing.rst
@@ -9,7 +9,7 @@ It also can be interpreted as a data cropping or data slicing operation.
 
 Reduction of the input data is often done to remove unnecessary/useless
 information, and to accelerate the processing time. It is also recommended to use
-when searching for optimal parameter values, see :ref:`parameter_tuning`. Skip to
+when searching for optimal parameter values, see :ref:`parameter_sweeping`. Skip to
 :ref:`previewing_enable` for information about how to use it in HTTomo.
 
 Previewing in the loader
diff --git a/docs/source/pipelines/yaml.rst b/docs/source/pipelines/yaml.rst
index d52029a68..e1087a4c7 100644
--- a/docs/source/pipelines/yaml.rst
+++ b/docs/source/pipelines/yaml.rst
@@ -54,6 +54,13 @@ DLS Specific templates
 
 Parameter Sweeps templates
 ----------------------------
-Those templates demonstrate how to perform sweeps across multiple values a single parameter. See more on :ref:`parameter_tuning`.
 
-To be added in the forthcoming releases. 
+These templates demonstrate how to perform a sweep across multiple values of a
+single parameter (see :ref:`parameter_sweeping` for more details).
+
+.. dropdown:: Parameter sweep over 10 CoR values (`center` param) in recon
+   method, and saving the result as tiffs
+
+   .. literalinclude:: ../../../tests/samples/pipeline_template_examples/parameter-sweep-cor.yaml
+       :language: yaml
+       :emphasize-lines: 9-11,37-40
diff --git a/httomo/base_block.py b/httomo/base_block.py
new file mode 100644
index 000000000..9334f6991
--- /dev/null
+++ b/httomo/base_block.py
@@ -0,0 +1,124 @@
+from typing import Tuple
+
+import numpy as np
+
+from httomo.block_interfaces import BlockData, BlockTransfer, generic_array
+from httomo.runner.auxiliary_data import AuxiliaryData
+from httomo.utils import gpu_enabled, make_3d_shape_from_array, xp
+
+
+class BaseBlock(BlockData, BlockTransfer):
+    """
+    Base block class providing default implementations for the data
+    transferring/getting/setting behaviour needed for a block type to be processed by
+    implementors of `MethodWrapper`. Ie, this class provides default implementations for the
+    `BlockTransfer` and `BlockData` protocols.
+
+    Note that the data indexing behaviour described in `DataIndexing` is not implemented in
+    this class. If the default implementations for data transferring/getting/setting in this
+    class are acceptable: inherit from `BaseBlock`, override where necessary, and implement
+    `DataIndexing` in order to implement the `Block` protocol.
+    """
+
+    def __init__(self, data: np.ndarray, aux_data: AuxiliaryData) -> None:
+        self._data = data
+        self._aux_data = aux_data
+
+    def __dir__(self) -> list[str]:
+        """Return only those properties that are relevant for the data"""
+        return ["data", "angles", "angles_radians", "darks", "flats", "dark", "flat"]
+
+    @property
+    def data(self) -> generic_array:
+        return self._data
+
+    @data.setter
+    def data(self, new_data: generic_array):
+        self._data = new_data
+
+    @property
+    def aux_data(self) -> AuxiliaryData:
+        return self._aux_data
+
+    def _empty_aux_array(self):
+        empty_shape = list(self._data.shape)
+        return np.empty_like(self._data, shape=empty_shape)
+
+    @property
+    def angles(self) -> np.ndarray:
+        return self._aux_data.get_angles()
+
+    @angles.setter
+    def angles(self, new_angles: np.ndarray):
+        self._aux_data.set_angles(new_angles)
+
+    @property
+    def angles_radians(self) -> np.ndarray:
+        return self.angles
+
+    @angles_radians.setter
+    def angles_radians(self, new_angles: np.ndarray):
+        self.angles = new_angles
+
+    @property
+    def darks(self) -> generic_array:
+        darks = self._aux_data.get_darks(self.is_gpu)
+        if darks is None:
+            darks = self._empty_aux_array()
+        return darks
+
+    @darks.setter
+    def darks(self, darks: generic_array):
+        self._aux_data.set_darks(darks)
+
+    # alias
+    @property
+    def dark(self) -> generic_array:
+        return self.darks
+
+    @dark.setter
+    def dark(self, darks: generic_array):
+        self.darks = darks
+
+    @property
+    def flats(self) -> generic_array:
+        flats = self._aux_data.get_flats(self.is_gpu)
+        if flats is None:
+            flats = self._empty_aux_array()
+        return flats
+
+    @flats.setter
+    def flats(self, flats: generic_array):
+        self._aux_data.set_flats(flats)
+
+    # alias
+    @property
+    def flat(self) -> generic_array:
+        return self.flats
+
+    @flat.setter
+    def flat(self, flats: generic_array):
+        self.flats = flats
+
+    @property
+    def shape(self) -> Tuple[int, int, int]:
+        """Shape of the data in this block"""
+        return make_3d_shape_from_array(self._data)
+
+    def to_gpu(self):
+        if not gpu_enabled:
+            raise ValueError("no GPU available")
+        self._data = xp.asarray(self.data, order="C")
+
+    def to_cpu(self):
+        if not gpu_enabled:
+            return
+        self._data = xp.asnumpy(self.data, order="C")
+
+    @property
+    def is_gpu(self) -> bool:
+        return not self.is_cpu
+
+    @property
+    def is_cpu(self) -> bool:
+        return getattr(self._data, "device", None) is None
diff --git a/httomo/block_interfaces.py b/httomo/block_interfaces.py
new file mode 100644
index 000000000..0cbad87d7
--- /dev/null
+++ b/httomo/block_interfaces.py
@@ -0,0 +1,142 @@
+from typing import Protocol, Tuple, TypeAlias, TypeVar
+
+import numpy as np
+
+from httomo.runner.auxiliary_data import AuxiliaryData
+from httomo.utils import xp
+
+
+generic_array: TypeAlias = np.ndarray | xp.ndarray
+
+
+class BlockTransfer(Protocol):
+    """
+    Data transferring behaviour required for a block type to be processed by implementors of
+    `MethodWrapper`
+    """
+
+    def to_gpu(self): ...  # pragma: no cover
+
+    def to_cpu(self): ...  # pragma: no cover
+
+    @property
+    def is_gpu(self) -> bool: ...  # pragma: no cover
+
+    @property
+    def is_cpu(self) -> bool: ...  # pragma: no cover
+
+
+class BlockData(Protocol):
+    """
+    Data setting/getting behaviour required for a block type to be processed by implementors of
+    `MethodWrapper`
+    """
+
+    def __dir__(self) -> list[str]:
+        """
+        Return at least the following properties: `data`, `angles`, `angles_radians`, `darks`,
+        `flats`, `dark`, `flat`
+        """
+        ...  # pragma: no cover
+
+    @property
+    def data(self) -> generic_array: ...  # pragma: no cover
+
+    @data.setter
+    def data(self, new_data: generic_array): ...  # pragma: no cover
+
+    @property
+    def aux_data(self) -> AuxiliaryData: ...  # pragma: no cover
+
+    @property
+    def angles(self) -> np.ndarray: ...  # pragma: no cover
+
+    @angles.setter
+    def angles(self, new_angles: np.ndarray): ...  # pragma: no cover
+
+    @property
+    def angles_radians(self) -> np.ndarray: ...  # pragma: no cover
+
+    @angles_radians.setter
+    def angles_radians(self, new_angles: np.ndarray): ...  # pragma: no cover
+
+    @property
+    def darks(self) -> generic_array: ...  # pragma: no cover
+
+    @darks.setter
+    def darks(self, darks: generic_array): ...  # pragma: no cover
+
+    # alias
+    @property
+    def dark(self) -> generic_array: ...  # pragma: no cover
+
+    @dark.setter
+    def dark(self, darks: generic_array): ...  # pragma: no cover
+
+    @property
+    def flats(self) -> generic_array: ...  # pragma: no cover
+
+    @flats.setter
+    def flats(self, flats: generic_array): ...  # pragma: no cover
+
+    # alias
+    @property
+    def flat(self) -> generic_array: ...  # pragma: no cover
+
+    @flat.setter
+    def flat(self, flats: generic_array): ...  # pragma: no cover
+
+    @property
+    def shape(self) -> Tuple[int, int, int]:
+        """Shape of the data in this block"""
+        ...  # pragma: no cover
+
+
+class BlockIndexing(Protocol):
+    """
+    Data indexing behaviour required for a block type to be processed by implementors of
+    `MethodWrapper`
+    """
+
+    @property
+    def chunk_index(self) -> Tuple[int, int, int]:
+        """The index of this block within the chunk handled by the current process"""
+        ...  # pragma: no cover
+
+    @property
+    def chunk_shape(self) -> Tuple[int, int, int]:
+        """Shape of the full chunk handled by the current process"""
+        ...  # pragma: no cover
+
+    @property
+    def global_index(self) -> Tuple[int, int, int]:
+        """The index of this block within the global data across all processes"""
+        ...  # pragma: no cover
+
+    @property
+    def global_shape(self) -> Tuple[int, int, int]:
+        """Shape of the global data across all processes"""
+        ...  # pragma: no cover
+
+    @property
+    def is_last_in_chunk(self) -> bool:
+        """
+        Check if the current dataset is the final one for the chunk handled by the current
+        process
+        """
+        ...  # pragma: no cover
+
+
+class Block(BlockData, BlockIndexing, BlockTransfer):
+    """
+    All behaviour required for a block type to be processed by implementors of
+    `MethodWrapper`
+    """
+
+    ...  # pragma: no cover
+
+
+# The type parameter `T` is defined for use in any method signature that takes in an
+# implementor of `Block` as a parameter and returns the *same* implementor of `Block` (such as
+# the `execute()` method signature in `MethodWrapper`)
+T = TypeVar("T", bound=Block)
diff --git a/httomo/cli.py b/httomo/cli.py
index 1a0981998..ed3812806 100644
--- a/httomo/cli.py
+++ b/httomo/cli.py
@@ -11,8 +11,10 @@
 from loguru import logger
 
 import httomo.globals
+from httomo.cli_utils import is_sweep_pipeline
 from httomo.logger import setup_logger
 from httomo.monitors import MONITORS_MAP, make_monitors
+from httomo.sweep_runner.param_sweep_runner import ParamSweepRunner
 from httomo.transform_layer import TransformLayer
 from httomo.yaml_checker import validate_yaml_config
 from httomo.runner.task_runner import TaskRunner
@@ -135,13 +137,8 @@ def run(
 ):
     """Run a pipeline defined in YAML on input data."""
 
-    # we use half the memory for blocks since we typically have inputs/output
-    memory_limit = transform_limit_str_to_bytes(max_memory) // 2
-
-    if max_cpu_slices < 1:
-        raise ValueError("max-cpu-slices must be greater or equal to 1")
-    httomo.globals.MAX_CPU_SLICES = max_cpu_slices
-
+    comm = MPI.COMM_WORLD
+    does_contain_sweep = is_sweep_pipeline(yaml_config)
     httomo.globals.SYSLOG_SERVER = syslog_host
     httomo.globals.SYSLOG_PORT = syslog_port
 
@@ -153,35 +150,12 @@ def run(
     else:
         httomo.globals.run_out_dir = out_dir.joinpath(create_folder)
 
-    comm = MPI.COMM_WORLD
+    # Various initialisation tasks
     if comm.rank == 0:
-        # Create timestamped output directory
         Path.mkdir(httomo.globals.run_out_dir, exist_ok=True)
-
-        # Copy YAML pipeline file to output directory
         copy(yaml_config, httomo.globals.run_out_dir)
-
     setup_logger(Path(httomo.globals.run_out_dir))
 
-    # try to access the GPU with the ID given
-    try:
-        import cupy as cp
-
-        gpu_count = cp.cuda.runtime.getDeviceCount()
-
-        if gpu_id != -1:
-            if gpu_id not in range(0, gpu_count):
-                raise ValueError(
-                    f"GPU Device not available for access. Use a GPU ID in the range: 0 to {gpu_count} (exclusive)"
-                )
-
-            cp.cuda.Device(gpu_id).use()
-
-        httomo.globals.gpu_id = gpu_id
-
-    except ImportError:
-        pass  # silently pass and run if the CPU pipeline is given
-
     # instantiate UiLayer class for pipeline build
     init_UiLayer = UiLayer(yaml_config, in_data_file, comm=comm)
     pipeline = init_UiLayer.build_pipeline()
@@ -190,21 +164,33 @@ def run(
     tr = TransformLayer(comm=comm, save_all=save_all)
     pipeline = tr.transform(pipeline)
 
-    # Run the pipeline using Taskrunner, with temp dir or reslice dir
-    mon = make_monitors(monitor)
-    ctx: AbstractContextManager = nullcontext(reslice_dir)
-    if reslice_dir is None:
-        ctx = tempfile.TemporaryDirectory()
-    with ctx as tmp_dir:
-        runner = TaskRunner(
-            pipeline,
-            Path(tmp_dir),
-            monitor=mon,
-            memory_limit_bytes=memory_limit,
-        )
-        runner.execute()
-        if mon is not None:
-            mon.write_results(monitor_output)
+    if not does_contain_sweep:
+        # we use half the memory for blocks since we typically have inputs/output
+        memory_limit = transform_limit_str_to_bytes(max_memory) // 2
+
+        if max_cpu_slices < 1:
+            raise ValueError("max-cpu-slices must be greater or equal to 1")
+        httomo.globals.MAX_CPU_SLICES = max_cpu_slices
+
+        _set_gpu_id(gpu_id)
+
+        # Run the pipeline using Taskrunner, with temp dir or reslice dir
+        mon = make_monitors(monitor)
+        ctx: AbstractContextManager = nullcontext(reslice_dir)
+        if reslice_dir is None:
+            ctx = tempfile.TemporaryDirectory()
+        with ctx as tmp_dir:
+            runner = TaskRunner(
+                pipeline,
+                Path(tmp_dir),
+                monitor=mon,
+                memory_limit_bytes=memory_limit,
+            )
+            runner.execute()
+            if mon is not None:
+                mon.write_results(monitor_output)
+    else:
+        ParamSweepRunner(pipeline).execute()
 
 
 def _check_yaml(yaml_config: Path, in_data: Path):
@@ -225,3 +211,23 @@ def transform_limit_str_to_bytes(limit_str: str):
             return int(limit_str)
     except ValueError:
         raise ValueError(f"invalid memory limit string {limit_str}")
+
+
+def _set_gpu_id(gpu_id: int):
+    try:
+        import cupy as cp
+
+        gpu_count = cp.cuda.runtime.getDeviceCount()
+
+        if gpu_id != -1:
+            if gpu_id not in range(0, gpu_count):
+                raise ValueError(
+                    f"GPU Device not available for access. Use a GPU ID in the range: 0 to {gpu_count} (exclusive)"
+                )
+
+            cp.cuda.Device(gpu_id).use()
+
+        httomo.globals.gpu_id = gpu_id
+
+    except ImportError:
+        pass  # silently pass and run if the CPU pipeline is given
diff --git a/httomo/cli_utils.py b/httomo/cli_utils.py
new file mode 100644
index 000000000..5cadd4291
--- /dev/null
+++ b/httomo/cli_utils.py
@@ -0,0 +1,58 @@
+import importlib.util
+
+from pathlib import Path
+from typing import Any, Dict, List
+
+from httomo.ui_layer import PipelineConfig
+
+
+MANUAL_SWEEP_TAG = "!Sweep"
+RANGE_SWEEP_TAG = "!SweepRange"
+PYTHON_SCRIPT_MODULE_NAME = "python_script_module"
+
+
+def is_sweep_pipeline(file_path: Path) -> bool:
+    """
+    Determine if the given pipeline contains a parameter sweep
+    """
+    extension = file_path.suffix.lower()
+    if extension == ".yaml":
+        return _does_yaml_pipeline_contain_sweep(file_path)
+    elif extension == ".py":
+        return _does_python_pipeline_contain_sweep(file_path)
+    else:
+        raise ValueError(f"Unrecognised pipeline file extension: {extension}")
+
+
+def _does_yaml_pipeline_contain_sweep(file_path: Path) -> bool:
+    """
+    Check for `!Sweep` or `!SweepRange` tags
+    """
+    with open(file_path) as f:
+        for line in f:
+            if MANUAL_SWEEP_TAG in line or RANGE_SWEEP_TAG in line:
+                return True
+    return False
+
+
+def _does_python_pipeline_contain_sweep(file_path: Path) -> bool:
+    """
+    Check for tuple value in any method params
+    """
+    # NOTE: Used information in the following link:
+    # https://docs.python.org/3/library/importlib.html#importing-a-source-file-directly
+    spec = importlib.util.spec_from_file_location(PYTHON_SCRIPT_MODULE_NAME, file_path)
+    assert spec is not None
+    module = importlib.util.module_from_spec(spec)
+    assert spec.loader is not None
+    spec.loader.exec_module(module)
+    pipeline_config: PipelineConfig = module.methods_to_list()
+
+    all_methods_params: List[Dict[str, Any]] = [
+        method["parameters"] for method in pipeline_config
+    ]
+    for params in all_methods_params:
+        for value in params.values():
+            if isinstance(value, tuple):
+                return True
+    return False
diff --git a/httomo/data/param_sweep_store.py b/httomo/data/param_sweep_store.py
new file mode 100644
index 000000000..27760bb0e
--- /dev/null
+++ b/httomo/data/param_sweep_store.py
@@ -0,0 +1,139 @@
+from __future__ import annotations
+from typing import Literal, Optional, Tuple
+
+import numpy as np
+
+from httomo.runner.auxiliary_data import AuxiliaryData
+from httomo.sweep_runner.param_sweep_block import ParamSweepBlock
+from httomo.sweep_runner.param_sweep_store_interfaces import ParamSweepSource
+
+
+class ParamSweepReader(ParamSweepSource):
+    """
+    Read parameter sweep results, extracting middle slices along the `detector_y` dim
+    """
+
+    def __init__(self, source: ParamSweepWriter) -> None:
+        self._no_of_sweeps = source.no_of_sweeps
+        self._extract_dim: Literal[1] = 1
+        self._single_shape = source.single_shape
+        self._total_shape = source.total_shape
+        assert (
+            source._data is not None
+        ), "Reader should be created from writer with data not `None`"
+        self._data = source._data
+        self._aux_data = source.aux_data
+
+    @property
+    def no_of_sweeps(self) -> int:
+        return self._no_of_sweeps
+
+    @property
+    def extract_dim(self) -> Literal[0, 1, 2]:
+        return self._extract_dim
+
+    @property
+    def single_shape(self) -> Tuple[int, int, int]:
+        return self._single_shape
+
+    @property
+    def total_shape(self) -> Tuple[int, int, int]:
+        return self._total_shape
+
+    @property
+    def aux_data(self) -> AuxiliaryData:
+        return self._aux_data
+
+    def read_sweep_results(self) -> ParamSweepBlock:
+        slices = [slice(None, None, 1)] * 3
+        slices_per_sweep = self.single_shape[self.extract_dim]
+        first_middle_slice_index = slices_per_sweep // 2
+        detector_y_slice = slice(first_middle_slice_index, None, slices_per_sweep)
+        slices[self.extract_dim] = detector_y_slice
+        return ParamSweepBlock(
+            data=self._data[slices[0], slices[1], slices[2]],
+            aux_data=self.aux_data,
+        )
+
+
+class ParamSweepWriter:
+    """Write parameter sweep results, concatenating them along the `detector_y` dim"""
+
+    def __init__(self, no_of_sweeps: int) -> None:
+        self._concat_dim: Literal[1] = 1
+        self._no_of_sweeps = no_of_sweeps
+        self._no_of_sweeps_written: int = 0
+        self._single_shape: Optional[Tuple[int, int, int]] = None
+        self._total_shape: Optional[Tuple[int, int, int]] = None
+        self._data: Optional[np.ndarray] = None
+        self._slices_per_sweep: Optional[int] = None
+
+    @property
+    def no_of_sweeps(self) -> int:
+        return self._no_of_sweeps
+
+    @property
+    def concat_dim(self) -> Literal[0, 1, 2]:
+        return self._concat_dim
+
+    @property
+    def single_shape(self) -> Tuple[int, int, int]:
+        if self._single_shape is None:
+            err_str = (
+                "Shape of single sweep result isn't known until the first write has occurred"
+            )
+            raise ValueError(err_str)
+        return self._single_shape
+
+    @property
+    def total_shape(self) -> Tuple[int, int, int]:
+        if self._total_shape is None:
+            err_str = (
+                "Shape of full array holding sweep results isn't known until the first "
+                "write has occurred"
+            )
+            raise ValueError(err_str)
+        return self._total_shape
+
+    def make_reader(self) -> ParamSweepReader:
+        if self._data is None:
+            raise ValueError("Cannot make reader when no data has been written yet")
+        return ParamSweepReader(self)
+
+    @property
+    def no_of_sweeps_written(self) -> int:
+        return self._no_of_sweeps_written
+
+    def increment_no_of_sweeps_written(self):
+        self._no_of_sweeps_written += 1
+
+    @property
+    def slices_per_sweep(self) -> int:
+        return self._slices_per_sweep
+
+    @property
+    def aux_data(self) -> AuxiliaryData:
+        return self._aux_data
+
+    def write_sweep_result(self, block: ParamSweepBlock):
+        block.to_cpu()
+        if self._data is None:
+            self._single_shape = block.shape
+            self._total_shape = (
+                self.single_shape[0],
+                self.single_shape[1] * self.no_of_sweeps,
+                self.single_shape[2],
+            )
+            self._slices_per_sweep = self.single_shape[self.concat_dim]
+            self._data = np.empty(shape=self.total_shape, dtype=block.data.dtype)
+            self._aux_data = block.aux_data
+
+        slices = [slice(None, None, 1)] * 3
+        sweep_res_start = self.no_of_sweeps_written * self.slices_per_sweep
+        slices[self._concat_dim] = slice(
+            sweep_res_start,
+            sweep_res_start + self.slices_per_sweep,
+            1,
+        )
+        self._data[slices[0], slices[1], slices[2]] = block.data
+        self.increment_no_of_sweeps_written()
diff --git a/httomo/method_wrappers/datareducer.py b/httomo/method_wrappers/datareducer.py
index f2b25c760..42eb58382 100644
--- a/httomo/method_wrappers/datareducer.py
+++ b/httomo/method_wrappers/datareducer.py
@@ -1,5 +1,5 @@
+from httomo.block_interfaces import T
 from httomo.method_wrappers.generic import GenericMethodWrapper
-from httomo.runner.dataset import DataSetBlock
 from httomo.runner.methods_repository_interface import MethodRepository
 
 from mpi4py.MPI import Comm
@@ -39,7 +39,7 @@ def __init__(
         ), "Only data_reducer is supported at the moment"
         self._flats_darks_processed = False
 
-    def execute(self, block: DataSetBlock) -> DataSetBlock:
+    def execute(self, block: T) -> T:
         # check if data needs to be transfered host <-> device
         block = self._transfer_data(block)
 
diff --git a/httomo/method_wrappers/dezinging.py b/httomo/method_wrappers/dezinging.py
index 5dfc1896a..3ab122dac 100644
--- a/httomo/method_wrappers/dezinging.py
+++ b/httomo/method_wrappers/dezinging.py
@@ -1,5 +1,5 @@
+from httomo.block_interfaces import T
 from httomo.method_wrappers.generic import GenericMethodWrapper
-from httomo.runner.dataset import DataSetBlock
 from httomo.runner.method_wrapper import GpuTimeInfo
 from httomo.runner.methods_repository_interface import MethodRepository
 
@@ -43,7 +43,7 @@ def __init__(
         ), "Only remove_outlier is supported at the moment"
         self._flats_darks_processed = False
 
-    def execute(self, block: DataSetBlock) -> DataSetBlock:
+    def execute(self, block: T) -> T:
         self._gpu_time_info = GpuTimeInfo()
         # check if data needs to be transfered host <-> device
         block = self._transfer_data(block)
diff --git a/httomo/method_wrappers/generic.py b/httomo/method_wrappers/generic.py
index 5ff54482c..b2b9ea436 100644
--- a/httomo/method_wrappers/generic.py
+++ b/httomo/method_wrappers/generic.py
@@ -1,6 +1,6 @@
 import httomo.globals
+from httomo.block_interfaces import T, Block
 from httomo.data import mpiutil
-from httomo.runner.dataset import DataSetBlock
 from httomo.runner.gpu_utils import gpumem_cleanup
 from httomo.runner.method_wrapper import (
     GpuTimeInfo,
@@ -222,7 +222,7 @@ def recon_algorithm(self) -> Optional[str]:
     def _build_kwargs(
         self,
         dict_params: MethodParameterDictType,
-        dataset: Optional[DataSetBlock] = None,
+        dataset: Optional[Block] = None,
     ) -> Dict[str, Any]:
         # first parameter is always the data (if given)
         ret: Dict[str, Any] = dict()
@@ -265,7 +265,7 @@ def _resolve_output_refs(self, dict_params: Dict[str, Any]) -> Dict[str, Any]:
             ret[k] = v
         return ret
 
-    def execute(self, block: DataSetBlock) -> DataSetBlock:
+    def execute(self, block: T) -> T:
         """Execute functions for external packages.
 
         Developer note: Derived classes may override this function or any of the methods
@@ -274,13 +274,13 @@ def execute(self, block: DataSetBlock) -> DataSetBlock:
         Parameters
         ----------
 
-        block: DataSetBlock
+        block: T (implements `Block`)
             A numpy or cupy dataset, mutable (method might work in-place).
 
         Returns
         -------
 
-        DataSetBlock
+        T (implements `Block`)
             A CPU or GPU-based dataset object with the output
         """
 
@@ -296,17 +296,17 @@ def execute(self, block: DataSetBlock) -> DataSetBlock:
 
         return block
 
-    def _run_method(self, block: DataSetBlock, args: Dict[str, Any]) -> DataSetBlock:
+    def _run_method(self, block: T, args: Dict[str, Any]) -> T:
         """Runs the actual method - override if special handling is required
         Or side outputs are produced."""
         ret = self._method(**args)
         block = self._process_return_type(ret, block)
         return block
 
-    def _process_return_type(self, ret: Any, input_block: DataSetBlock) -> DataSetBlock:
-        """Checks return type of method call and assigns/creates return DataSetBlock object.
-        Override this method if a return type different from ndarray is produced and
-        needs to be processed in some way.
+    def _process_return_type(self, ret: Any, input_block: T) -> T:
+        """Checks return type of method call and assigns/creates a `T` object that
+        implements `Block` (the same type `T` that was given as input). Override this method if
+        a return type different from ndarray is produced and needs to be processed in some way.
         """
         if type(ret) != np.ndarray and type(ret) != xp.ndarray:
             raise ValueError(
@@ -323,7 +323,7 @@ def get_side_output(self) -> Dict[str, Any]:
         follow in the pipeline"""
         return {v: self._side_output[k] for k, v in self._output_mapping.items()}
 
-    def _transfer_data(self, block: DataSetBlock) -> DataSetBlock:
+    def _transfer_data(self, block: T) -> T:
         if not self.cupyrun:
             with catchtime() as t:
                 block.to_cpu()
@@ -351,12 +351,12 @@ def _transform_params(
         dictionary, for example to rename some of them or inspect them in some way"""
         return dict_params
 
-    def _preprocess_data(self, block: DataSetBlock) -> DataSetBlock:
+    def _preprocess_data(self, block: T) -> T:
         """Hook for derived classes to implement proprocessing steps, after the data has been
         transferred and before the method is called"""
         return block
 
-    def _postprocess_data(self, block: DataSetBlock) -> DataSetBlock:
+    def _postprocess_data(self, block: T) -> T:
         """Hook for derived classes to implement postprocessing steps, after the method has been
         called"""
         return block
diff --git a/httomo/method_wrappers/images.py b/httomo/method_wrappers/images.py
index 6e527563c..22c20ffca 100644
--- a/httomo/method_wrappers/images.py
+++ b/httomo/method_wrappers/images.py
@@ -1,6 +1,6 @@
 import httomo.globals
+from httomo.block_interfaces import T
 from httomo.method_wrappers.generic import GenericMethodWrapper
-from httomo.runner.dataset import DataSetBlock
 from httomo.runner.method_wrapper import GpuTimeInfo
 from httomo.runner.methods_repository_interface import MethodRepository
 from httomo.utils import _get_slicing_dim, catchtime, xp
@@ -50,10 +50,7 @@ def __init__(
 
     # Images execute is leaving original data on the device where it is,
     # but gives the method a CPU copy of the data.
-    def execute(
-        self,
-        block: DataSetBlock,
-    ) -> DataSetBlock:
+    def execute(self, block: T) -> T:
         self._gpu_time_info = GpuTimeInfo()
         config_params = self._config_params
         if "offset" in self.parameters:
diff --git a/httomo/method_wrappers/reconstruction.py b/httomo/method_wrappers/reconstruction.py
index d02edeb8f..074b52db2 100644
--- a/httomo/method_wrappers/reconstruction.py
+++ b/httomo/method_wrappers/reconstruction.py
@@ -1,5 +1,5 @@
+from httomo.block_interfaces import T, Block
 from httomo.method_wrappers.generic import GenericMethodWrapper
-from httomo.runner.dataset import DataSetBlock
 from httomo.runner.method_wrapper import MethodParameterDictType
 
 
@@ -16,7 +16,7 @@ class ReconstructionWrapper(GenericMethodWrapper):
     def should_select_this_class(cls, module_path: str, method_name: str) -> bool:
         return module_path.endswith(".algorithm")
 
-    def _preprocess_data(self, block: DataSetBlock) -> DataSetBlock:
+    def _preprocess_data(self, block: T) -> T:
         # this is essential for the angles cutting below to be valid
         assert self.pattern == Pattern.sinogram, "reconstruction methods must be sinogram"
         
@@ -31,7 +31,7 @@ def _preprocess_data(self, block: DataSetBlock) -> DataSetBlock:
     def _build_kwargs(
         self,
         dict_params: MethodParameterDictType,
-        dataset: Optional[DataSetBlock] = None,
+        dataset: Optional[Block] = None,
     ) -> Dict[str, Any]:
         assert dataset is not None, "Reconstruction wrappers require a dataset"
         # for recon methods, we assume that the second parameter is the angles in all cases
diff --git a/httomo/method_wrappers/rotation.py b/httomo/method_wrappers/rotation.py
index fc28f7925..67f2f0b68 100644
--- a/httomo/method_wrappers/rotation.py
+++ b/httomo/method_wrappers/rotation.py
@@ -1,5 +1,5 @@
+from httomo.block_interfaces import T, Block
 from httomo.method_wrappers.generic import GenericMethodWrapper
-from httomo.runner.dataset import DataSetBlock
 from httomo.runner.method_wrapper import MethodParameterDictType
 from httomo.runner.methods_repository_interface import MethodRepository
 from httomo.utils import Pattern, catchtime, log_once, xp
@@ -62,7 +62,7 @@ def __init__(
     def _build_kwargs(
         self,
         dict_params: MethodParameterDictType,
-        dataset: Optional[DataSetBlock] = None,
+        dataset: Optional[Block] = None,
     ) -> Dict[str, Any]:
         assert (
             dataset is not None
@@ -100,7 +100,7 @@ def _gather_sino_slice(self, global_shape: Tuple[int, int, int]):
             )
             return None
 
-    def _run_method(self, block: DataSetBlock, args: Dict[str, Any]) -> DataSetBlock:
+    def _run_method(self, block: T, args: Dict[str, Any]) -> T:
         """Different CoR estimation methods require different inputs, e.g., the VoCentering
         method needs the full sinogram, while the PhaseCorrelation method needs specific projections.
         """
@@ -207,7 +207,7 @@ def normalize_sino(
             sino -= darks1d / denom
         return sino[:, np.newaxis, :]
 
-    def _process_return_type(self, ret: Any, input_block: DataSetBlock) -> DataSetBlock:
+    def _process_return_type(self, ret: Any, input_block: T) -> T:
         if type(ret) == tuple:
             # cor, overlap, side, overlap_position - from find_center_360
             self._side_output["cor"] = float(ret[0])
diff --git a/httomo/method_wrappers/save_intermediate.py b/httomo/method_wrappers/save_intermediate.py
index 08822beb6..f4d1003d1 100644
--- a/httomo/method_wrappers/save_intermediate.py
+++ b/httomo/method_wrappers/save_intermediate.py
@@ -4,8 +4,8 @@
 import weakref
 from mpi4py.MPI import Comm
 import httomo
+from httomo.block_interfaces import T
 from httomo.method_wrappers.generic import GenericMethodWrapper
-from httomo.runner.dataset import DataSetBlock
 from httomo.runner.loader import LoaderInterface
 from httomo.runner.method_wrapper import GpuTimeInfo, MethodWrapper
 from httomo.runner.methods_repository_interface import MethodRepository
@@ -48,7 +48,7 @@ def __init__(self,
         # make sure file gets closed properly
         weakref.finalize(self, self._file.close)
         
-    def execute(self, block: DataSetBlock) -> DataSetBlock:
+    def execute(self, block: T) -> T:
         # we overwrite the whole execute method here, as we do not need any of the helper
         # methods from the Generic Wrapper
         # What we know:
diff --git a/httomo/runner/dataset.py b/httomo/runner/dataset.py
index a62ca6c46..e6cb7e344 100644
--- a/httomo/runner/dataset.py
+++ b/httomo/runner/dataset.py
@@ -1,23 +1,16 @@
-from typing import Optional, Tuple, Union
-from typing_extensions import TypeAlias
-from httomo.runner.auxiliary_data import AuxiliaryData
-from httomo.utils import gpu_enabled, xp
-import numpy as np
+from typing import Optional, Tuple
 
-from httomo.utils import make_3d_shape_from_shape
-from httomo.utils import make_3d_shape_from_array
+import numpy as np
 
+from httomo.base_block import BaseBlock, generic_array
+from httomo.runner.auxiliary_data import AuxiliaryData
+from httomo.utils import make_3d_shape_from_array, make_3d_shape_from_shape
 
-class DataSetBlock:
-    """Represents a slice/block of a dataset, as returned returned by `make_block`
-    in a DataSet object. It is a DataSet (inherits from it) and users can mostly
-    ignore the fact that it's just a view.
 
-    It stores the base object internally and routes all calls for the auxilliary
-    arrays to the base object (darks/flats/angles). It does not store these directly.
+class DataSetBlock(BaseBlock):
+    """
+    Data storage type for block processing in high throughput runs
     """
-    
-    generic_array: TypeAlias = Union[np.ndarray, xp.ndarray]
 
     def __init__(
         self,
@@ -29,8 +22,7 @@ def __init__(
         global_shape: Optional[Tuple[int, int, int]] = None,
         chunk_shape: Optional[Tuple[int, int, int]] = None,
     ):
-        self._data = data
-        self._aux_data = aux_data
+        super().__init__(data, aux_data)
         self._slicing_dim = slicing_dim
         self._block_start = block_start
         self._chunk_start = chunk_start
@@ -72,15 +64,6 @@ def _check_inconsistencies(self):
         
         assert not any(self.chunk_shape[i] != self.global_shape[i] for i in range(3) if i != self.slicing_dim)
 
-    @property
-    def aux_data(self) -> AuxiliaryData:
-        return self._aux_data
-    
-    @property
-    def shape(self) -> Tuple[int, int, int]:
-        """Shape of the data in this block"""
-        return make_3d_shape_from_array(self._data)
-
     @property
     def chunk_index(self) -> Tuple[int, int, int]:
         """The index of this block within the chunk handled by the current process"""
@@ -101,30 +84,6 @@ def global_shape(self) -> Tuple[int, int, int]:
         """Shape of the global data across all processes"""
         return self._global_shape
     
-    @property
-    def is_cpu(self) -> bool:
-        return getattr(self._data, "device", None) is None
-    
-    @property
-    def is_gpu(self) -> bool:
-        return not self.is_cpu
-    
-    @property
-    def angles(self) -> np.ndarray:
-        return self._aux_data.get_angles()
-    
-    @angles.setter
-    def angles(self, new_angles: np.ndarray):
-        self._aux_data.set_angles(new_angles)
-    
-    @property
-    def angles_radians(self) -> np.ndarray:
-        return self.angles
-    
-    @angles_radians.setter
-    def angles_radians(self, new_angles: np.ndarray):
-        self.angles = new_angles
-
     @property
     def is_last_in_chunk(self) -> bool:
         """Check if the current dataset is the final one for the chunk handled by the current process"""
@@ -142,11 +101,7 @@ def _empty_aux_array(self):
         empty_shape[self.slicing_dim] = 0
         return np.empty_like(self._data, shape=empty_shape)
 
-    @property
-    def data(self) -> generic_array:
-        return self._data
-
-    @data.setter
+    @BaseBlock.data.setter
     def data(self, new_data: generic_array):
         global_shape = list(self._global_shape)
         chunk_shape = list(self._chunk_shape)
@@ -160,58 +115,3 @@ def data(self, new_data: generic_array):
         self._data = new_data
         self._global_shape = make_3d_shape_from_shape(global_shape)
         self._chunk_shape = make_3d_shape_from_shape(chunk_shape)
-
-    @property
-    def darks(self) -> generic_array:
-        darks = self._aux_data.get_darks(self.is_gpu)
-        if darks is None:
-            darks = self._empty_aux_array()
-        return darks
-
-    @darks.setter
-    def darks(self, darks: generic_array):
-        self._aux_data.set_darks(darks)
-        
-    # alias
-    @property
-    def dark(self) -> generic_array:
-        return self.darks
-    
-    @dark.setter
-    def dark(self, darks: generic_array):
-        self.darks = darks
-    
-    @property
-    def flats(self) -> generic_array:
-        flats = self._aux_data.get_flats(self.is_gpu)
-        if flats is None:
-            flats = self._empty_aux_array()
-        return flats
-
-    @flats.setter
-    def flats(self, flats: generic_array):
-        self._aux_data.set_flats(flats)
-        
-    # alias
-    @property
-    def flat(self) -> generic_array:
-        return self.flats
-    
-    @flat.setter
-    def flat(self, flats: generic_array):
-        self.flats = flats
-
-    def to_gpu(self):
-        if not gpu_enabled:
-            raise ValueError("no GPU available")
-        # from doc: if already on GPU, no copy is taken
-        self._data = xp.asarray(self.data, order="C")        
-
-    def to_cpu(self):
-        if not gpu_enabled:
-            return
-        self._data = xp.asnumpy(self.data, order="C")
-    
-    def __dir__(self) -> list[str]:
-        """Return only those properties that are relevant for the data"""
-        return ["data", "angles", "angles_radians", "darks", "flats", "dark", "flat"]
diff --git a/httomo/runner/method_wrapper.py b/httomo/runner/method_wrapper.py
index 1a0a319db..07c89e625 100644
--- a/httomo/runner/method_wrapper.py
+++ b/httomo/runner/method_wrapper.py
@@ -1,5 +1,5 @@
 from dataclasses import dataclass
-from httomo.runner.dataset import DataSetBlock
+from httomo.block_interfaces import T
 from httomo.runner.methods_repository_interface import GpuMemoryRequirement
 from httomo.utils import Pattern, xp
 
@@ -7,7 +7,7 @@
 from mpi4py import MPI
 
 import os
-from typing import Any, Callable, Dict, List, Literal, Optional, Protocol, Tuple, Union
+from typing import Any, Callable, Dict, List, Literal, Optional, Protocol, Tuple, TypeVar, Union
 
 MethodParameterValues = Union[str, bool, int, float, os.PathLike, np.ndarray, xp.ndarray]
 MethodParameterDictType = Dict[str, Union[MethodParameterValues, List[MethodParameterValues]]]
@@ -124,21 +124,25 @@ def __setitem__(self, key: str, value: MethodParameterValues):
     def append_config_params(self, params: MethodParameterDictType):
         """Appends to the configuration parameters all values that are in the given dictionary"""
         ... # pragma: nocover
-        
 
-    def execute(self, block: DataSetBlock) -> DataSetBlock:
+
+    # For a given type `T` that implements the `Block` protocol, the return type should be the
+    # same `T` as the input `T` (rather than allowing the input implementor of `Block` and the
+    # output implementor of `Block` to be different, which is what the signature
+    # `def execute(self, block: Block) -> Block` would imply).
+    def execute(self, block: T) -> T:
         """Execute the method.
 
         Parameters
         ----------
 
-        block: DataSetBlock
+        block: T (implements `Block`)
             A numpy or cupy dataset, mutable (method might work in-place).
 
         Returns
         -------
 
-        DataSetBlock
+        T (implements `Block`)
             A CPU or GPU-based dataset object with the output
         """
         ... # pragma: nocover
diff --git a/httomo/sweep_runner/__init__.py b/httomo/sweep_runner/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/httomo/sweep_runner/param_sweep_block.py b/httomo/sweep_runner/param_sweep_block.py
new file mode 100644
index 000000000..ed23893a6
--- /dev/null
+++ b/httomo/sweep_runner/param_sweep_block.py
@@ -0,0 +1,40 @@
+from typing import Tuple
+
+import numpy as np
+
+from httomo.base_block import BaseBlock
+from httomo.runner.auxiliary_data import AuxiliaryData
+from httomo.utils import make_3d_shape_from_array
+
+
+class ParamSweepBlock(BaseBlock):
+    """
+    Data storage type for block processing in parameter sweep runs
+    """
+
+    def __init__(
+        self,
+        data: np.ndarray,
+        aux_data: AuxiliaryData,
+    ) -> None:
+        super().__init__(data, aux_data)
+
+    @property
+    def chunk_index(self) -> Tuple[int, int, int]:
+        return (0, 0, 0)
+
+    @property
+    def chunk_shape(self) -> Tuple[int, int, int]:
+        return make_3d_shape_from_array(self._data)
+
+    @property
+    def global_index(self) -> Tuple[int, int, int]:
+        return (0, 0, 0)
+
+    @property
+    def global_shape(self) -> Tuple[int, int, int]:
+        return self.chunk_shape
+
+    @property
+    def is_last_in_chunk(self) -> bool:
+        return True
diff --git a/httomo/sweep_runner/param_sweep_runner.py b/httomo/sweep_runner/param_sweep_runner.py
new file mode 100644
index 000000000..f2666668f
--- /dev/null
+++ b/httomo/sweep_runner/param_sweep_runner.py
@@ -0,0 +1,207 @@
+import os
+from typing import Any, Dict, List, Optional, Tuple
+
+import tqdm
+
+import httomo
+from httomo.data.param_sweep_store import ParamSweepReader, ParamSweepWriter
+from httomo.runner.block_split import BlockSplitter
+from httomo.runner.method_wrapper import MethodWrapper
+from httomo.runner.pipeline import Pipeline
+from httomo.sweep_runner.param_sweep_block import ParamSweepBlock
+from httomo.sweep_runner.side_output_manager import SideOutputManager
+from httomo.sweep_runner.stages import NonSweepStage, Stages, SweepStage
+from httomo.utils import catchtime, log_exception, log_once
+
+
+class ParamSweepRunner:
+    def __init__(
+        self,
+        pipeline: Pipeline,
+        side_output_manager: SideOutputManager = SideOutputManager(),
+    ) -> None:
+        self._sino_slices_threshold = 7
+        self._pipeline = pipeline
+        self._side_output_manager = side_output_manager
+        self._block: Optional[ParamSweepBlock] = None
+        self._check_params_for_sweep()
+        self._stages = self.determine_stages()
+
+    @property
+    def block(self) -> ParamSweepBlock:
+        if self._block is None:
+            raise ValueError("Block from input data has not yet been loaded")
+        return self._block
+
+    def _check_params_for_sweep(self):
+        """
+        Check pipeline for the number of parameter sweeps present.
+
+        If none are defined, then raise an error. If more than one is defined, then also raise
+        an error, due to not supporting parameter sweeps over more than one parameter at a
+        time.
+        """
+        params = [m.config_params for m in self._pipeline]
+        no_of_sweeps = sum(map(_count_tuple_values, params))
+
+        if no_of_sweeps == 0:
+            err_str = "No parameter sweep detected in pipeline"
+            log_exception(err_str)
+            raise ValueError(err_str)
+
+        if no_of_sweeps > 1:
+            err_str = (
+                "Parameter sweep over more than one parameter detected in pipeline; "
+                "a sweep over only one parameter at a time is supported"
+            )
+            log_exception(err_str)
+            raise ValueError(err_str)
+
+    def determine_stages(self) -> Stages:
+        """
+        Groups methods into "before sweep", "sweep", and "after sweep" stages
+        """
+        before_sweep: List[MethodWrapper] = []
+        sweep_wrapper: Optional[MethodWrapper] = None
+        sweep_param_name: Optional[str] = None
+        sweep_param_vals: Optional[Tuple[Any, ...]] = None
+        after_sweep: List[MethodWrapper] = []
+
+        non_sweep_stage_methods: List[MethodWrapper] = before_sweep
+        for method in self._pipeline:
+            params = method.config_params
+            no_of_sweeps = sum(map(_count_tuple_values, [params]))
+            if no_of_sweeps == 0:
+                non_sweep_stage_methods.append(method)
+            else:
+                sweep_wrapper = method
+                sweep_param_name, sweep_param_vals = _get_param_sweep_name_and_vals(
+                    params
+                )
+                non_sweep_stage_methods = after_sweep
+
+        assert sweep_wrapper is not None
+        assert sweep_param_name is not None
+        assert sweep_param_vals is not None
+
+        return Stages(
+            before_sweep=NonSweepStage(before_sweep),
+            sweep=SweepStage(
+                method=sweep_wrapper,
+                param_name=sweep_param_name,
+                values=sweep_param_vals,
+            ),
+            after_sweep=NonSweepStage(after_sweep),
+        )
+
+    def prepare(self):
+        """
+        Load single block containing small number of sinogram slices in input data
+        """
+        source = self._pipeline.loader.make_data_source()
+
+        SINO_SLICING_DIM = 1
+        no_of_middle_slices = source.global_shape[SINO_SLICING_DIM]
+        if no_of_middle_slices > self._sino_slices_threshold:
+            err_str = (
+                "Parameter sweep runs support input data containing "
+                f"<= {self._sino_slices_threshold} sinogram slices, input data "
+                f"contains {no_of_middle_slices} slices"
+            )
+            raise ValueError(err_str)
+
+        log_once(f"Loading data with shape {source.global_shape}")
+        splitter = BlockSplitter(source, source.global_shape[source.slicing_dim])
+        dataset_block = splitter[0]
+        sweep_block = ParamSweepBlock(
+            data=dataset_block.data,
+            aux_data=dataset_block.aux_data,
+        )
+        self._block = sweep_block
+
+    def _execute_non_sweep_stage(self, stage: NonSweepStage):
+        assert self._block is not None
+        for method in stage.methods:
+            log_once(f"Running {method.method_name} ({method.package_name})")
+            self._side_output_manager.update_params(method)
+            self._block = method.execute(self._block)
+            self._side_output_manager.append(method.get_side_output())
+
+    def execute_before_sweep(self):
+        """Execute all methods before the parameter sweep"""
+        self._execute_non_sweep_stage(self._stages.before_sweep)
+
+    def execute_after_sweep(self):
+        """Execute all methods after the parameter sweep"""
+        self._execute_non_sweep_stage(self._stages.after_sweep)
+
+    def execute_sweep(self):
+        """Execute all param variations of the same method in the sweep"""
+        writer = ParamSweepWriter(len(self._stages.sweep.values))
+        method = self._stages.sweep.method
+
+        log_once(f"Running {method.method_name} ({method.package_name})")
+        sweep_info_str = (
+            f"    Parameter sweep over {len(self._stages.sweep.values)} values of "
+            f"parameter: {self._stages.sweep.param_name}"
+        )
+        log_once(sweep_info_str)
+
+        # Redirect tqdm progress bar output to /dev/null, and instead manually write sweep
+        # progress to logfile within loop
+        progress = tqdm.tqdm(
+            iterable=self._stages.sweep.values,
+            file=open(os.devnull, "w"),
+            unit="value",
+            ascii=True,
+        )
+        for val, _ in zip(self._stages.sweep.values, progress):
+            # Blocks are modified in-place by method wrappers, so a new block must be created
+            # that contains a copy of the input data to the sweep stage
+            block = ParamSweepBlock(
+                data=self.block.data.copy(),
+                aux_data=self.block.aux_data,
+            )
+            self._side_output_manager.update_params(method)
+            method.append_config_params({self._stages.sweep.param_name: val})
+            progress.set_postfix_str(f"{self._stages.sweep.param_name}={val}")
+            log_once(f"    {str(progress)}")
+            block = method.execute(block)
+            if len(method.get_side_output().keys()) > 0:
+                raise ValueError(
+                    "Producing a side output is not supported in parameter sweep methods"
+                )
+            writer.write_sweep_result(block)
+
+        log_once("    Finished parameter sweep")
+
+        reader = ParamSweepReader(writer)
+        self._block = reader.read_sweep_results()
+
+    def execute(self):
+        """Load input data and execute all stages (before sweep, sweep, after sweep)"""
+        with catchtime() as t:
+            log_once(f"See the full log file at: {httomo.globals.run_out_dir}/user.log")
+            self.prepare()
+            self.execute_before_sweep()
+            self.execute_sweep()
+            self.execute_after_sweep()
+        log_once(f"Pipeline finished. Took {t.elapsed:.3f}s")
+
+
+def _count_tuple_values(d: Dict[str, Any]) -> int:
+    return sum(1 for v in d.values() if isinstance(v, tuple))
+
+
+def _get_param_sweep_name_and_vals(d: Dict[str, Any]) -> Tuple[str, Tuple[Any, ...]]:
+    param_name: Optional[str] = None
+    sweep_vals: Optional[Tuple[Any, ...]] = None
+
+    for k, v in d.items():
+        if isinstance(v, tuple):
+            param_name = k
+            sweep_vals = v
+
+    assert param_name is not None
+    assert sweep_vals is not None
+    return param_name, sweep_vals
diff --git a/httomo/sweep_runner/param_sweep_store_interfaces.py b/httomo/sweep_runner/param_sweep_store_interfaces.py
new file mode 100644
index 000000000..8b9b5a2de
--- /dev/null
+++ b/httomo/sweep_runner/param_sweep_store_interfaces.py
@@ -0,0 +1,86 @@
+from typing import Literal, Protocol, Tuple
+
+from httomo.runner.auxiliary_data import AuxiliaryData
+from httomo.runner.dataset import DataSetBlock
+
+
+class ParamSweepSource(Protocol):
+    """Source for reading parameter sweep results from"""
+
+
+    @property
+    def no_of_sweeps(self) -> int:
+        """Number of parameter values used to generate sweep results"""
+        ...  # pragma: no cover
+
+    @property
+    def extract_dim(self) -> Literal[0, 1, 2]:
+        """Dimension to extract middle slices along - 0, 1, or 2"""
+        ...  # pragma: no cover
+
+    @property
+    def single_shape(self) -> Tuple[int, int, int]:
+        """Shape of single param sweep result"""
+        ...  # pragma: no cover
+
+    @property
+    def total_shape(self) -> Tuple[int, int, int]:
+        """
+        Shape of 3D array that contains all param sweep results concatenated along
+        `extract_dim`
+        """
+        ...  # pragma: no cover
+
+    @property
+    def aux_data(self) -> AuxiliaryData:
+        """Get auxiliary data"""
+        ...  # pragma: no cover
+
+    def read_sweep_results(self) -> DataSetBlock:
+        """
+        Returns a single block containing the middle slices extracted along `extract_dim` from
+        each sweep result
+        """
+        ...  # pragma: no cover
+
+
+class ParamSweepSink(Protocol):
+    """Sink for writing parameter sweep results to"""
+
+    @property
+    def no_of_sweeps(self) -> int:
+        """Number of parameter values to sweep over"""
+        ...  # pragma: no cover
+
+    @property
+    def concat_dim(self) -> Literal[0, 1, 2]:
+        """Dimension to concatenate sweep results along - 0, 1, or 2"""
+        ...  # pragma: no cover
+
+    @property
+    def single_shape(self) -> Tuple[int, int, int]:
+        """Shape of single param sweep result"""
+        ...  # pragma: no cover
+
+    @property
+    def total_shape(self) -> Tuple[int, int, int]:
+        """
+        Shape of 3D array to contain all param sweep results concatenated along `concat_dim`
+        """
+        ...  # pragma: no cover
+
+    def write_sweep_result(self, block: DataSetBlock):
+        """
+        Writes single sweep result contained in a block to store along the `concat_dim` axis
+        """
+        ...  # pragma: no cover
+
+    def finalize(self):
+        """Method to be called after writing all blocks is done"""
+        ...  # pragma: no cover
+
+    def make_reader(self) -> ParamSweepSource:
+        """
+        Return a source from the sink, from which the data written to the sink can be read
+        """
+        ...  # pragma: no cover
diff --git a/httomo/sweep_runner/param_sweep_yaml_loader.py b/httomo/sweep_runner/param_sweep_yaml_loader.py
new file mode 100644
index 000000000..36612c8bb
--- /dev/null
+++ b/httomo/sweep_runner/param_sweep_yaml_loader.py
@@ -0,0 +1,53 @@
+from typing import Tuple, Union
+
+import yaml
+import numpy as np
+
+from httomo.utils import log_exception
+
+
+class ParamSweepYamlLoader(yaml.SafeLoader):
+    """
+    Load an HTTomo pipeline YAML file containing a parameter sweep, via the `!Sweep` or
+    `!SweepRange` tag.
+    """
+
+    def sweep_manual(self, node) -> Tuple[Union[int, float], ...]:
+        """
+        A parameter sweep defined by explicitly given values to sweep over.
+        This will simply be a tuple of the given values.
+        """
+        return tuple(self.construct_sequence(node))
+
+    def sweep_range(self, node) -> Tuple[Union[int, float], ...]:
+        """
+        A parameter sweep defined by a given range. This will be a tuple of
+        values which are defined based on the start, stop, step values that are
+        in the dict.
+        """
+        # Form a dict from the YAML marked by `!SweepRange`
+        range_dict = self.construct_mapping(node)
+        # Check that only 3 keys are in the given dict/mapping: `start`, `stop`,
+        # and `step`
+        keys = range_dict.keys()
+        is_valid_range = "start" in keys and "stop" in keys and "step" in keys
+        if not is_valid_range:
+            err_str = (
+                "Please provide `start`, `stop`, `step` values when "
+                "specifying a range to peform a parameter sweep over."
+            )
+            log_exception(err_str)
+            raise ValueError(err_str)
+
+        # Define the range based on the start, stop, step values
+        param_vals = np.arange(
+            range_dict["start"], range_dict["stop"], range_dict["step"]
+        )
+        return tuple(param_vals)
+
+
+def get_param_sweep_yaml_loader() -> type[ParamSweepYamlLoader]:
+    loader = ParamSweepYamlLoader
+    loader.add_constructor("!Sweep", ParamSweepYamlLoader.sweep_manual)
+    loader.add_constructor("!SweepRange", ParamSweepYamlLoader.sweep_range)
+    return loader
diff --git a/httomo/sweep_runner/side_output_manager.py b/httomo/sweep_runner/side_output_manager.py
new file mode 100644
index 000000000..ea68711e6
--- /dev/null
+++ b/httomo/sweep_runner/side_output_manager.py
@@ -0,0 +1,31 @@
+from typing import Any, Dict, List
+
+from httomo.runner.method_wrapper import MethodWrapper
+
+
+class SideOutputManager:
+    """
+    Provides helper functionality to manage side outputs produced by method wrappers
+    """
+
+    def __init__(self) -> None:
+        self._side_outputs: Dict[str, Any] = dict()
+
+    @property
+    def labels(self) -> List[str]:
+        """Get labels/names of all side outputs"""
+        return list(self._side_outputs.keys())
+
+    def append(self, side_outputs: Dict[str, Any]):
+        """Merge given side outputs with existing side outputs"""
+        self._side_outputs |= side_outputs
+
+    def get(self, label: str) -> Any:
+        """Get the value of the given side output"""
+        return self._side_outputs[label]
+
+    def update_params(self, wrapper: MethodWrapper):
+        """Update parameters of given method wrapper with required side outputs"""
+        for k, v in self._side_outputs.items():
+            if k in wrapper.parameters:
+                wrapper[k] = v
diff --git a/httomo/sweep_runner/stages.py b/httomo/sweep_runner/stages.py
new file mode 100644
index 000000000..691bedcdd
--- /dev/null
+++ b/httomo/sweep_runner/stages.py
@@ -0,0 +1,26 @@
+from typing import Any, List, NamedTuple, Tuple
+
+from httomo.runner.method_wrapper import MethodWrapper
+
+
+class NonSweepStage(NamedTuple):
+    methods: List[MethodWrapper]
+
+
+class SweepStage(NamedTuple):
+    method: MethodWrapper
+    param_name: str
+    values: Tuple[Any, ...]
+
+
+class Stages(NamedTuple):
+    """
+    Groups method wrappers into three stages:
+    - wrappers representing methods that are before the sweep (excluding the loader)
+    - wrapper representing the method in the sweep to execute multiple times
+    - wrappers representing methods that are after the sweep
+    """
+
+    before_sweep: NonSweepStage
+    sweep: SweepStage
+    after_sweep: NonSweepStage
diff --git a/httomo/ui_layer.py b/httomo/ui_layer.py
index 339e89594..bf9e58450 100644
--- a/httomo/ui_layer.py
+++ b/httomo/ui_layer.py
@@ -1,5 +1,5 @@
 import yaml
-from typing import Any, Dict, List, TypeAlias
+from typing import Any, Dict, List, Optional, TypeAlias
 from importlib import import_module, util
 from pathlib import Path
 import os
@@ -17,6 +17,7 @@
 from httomo.loaders import make_loader
 from httomo.runner.loader import LoaderInterface
 from httomo.runner.output_ref import OutputRef
+from httomo.sweep_runner.param_sweep_yaml_loader import get_param_sweep_yaml_loader
 from httomo.transform_loader_params import parse_angles, parse_preview
 
 
@@ -262,7 +263,7 @@ def check_valid_ref_id(
 
 
 def yaml_loader(
-    file_path: Path, loader: yaml.Loader = yaml.FullLoader
+    file_path: Path, loader: Optional[type[yaml.SafeLoader]] = None
 ) -> PipelineConfig:
     """Loads provided yaml file and returns dict
 
@@ -277,6 +278,8 @@ def yaml_loader(
     -------
     PipelineConfig
     """
+    if loader is None:
+        loader = get_param_sweep_yaml_loader()
     with open(file_path, "r") as f:
         tasks_list = list(yaml.load_all(f, Loader=loader))
     return tasks_list[0]
diff --git a/httomo/yaml_checker.py b/httomo/yaml_checker.py
index 42992ed18..0a464cdb9 100644
--- a/httomo/yaml_checker.py
+++ b/httomo/yaml_checker.py
@@ -10,6 +10,10 @@
 
 from pathlib import Path
 
+from httomo.sweep_runner.param_sweep_yaml_loader import (
+    ParamSweepYamlLoader,
+    get_param_sweep_yaml_loader,
+)
 from httomo.ui_layer import (
     get_regex_pattern,
     get_ref_split,
@@ -232,8 +236,11 @@ def check_no_duplicated_keys(f: Path) -> bool:
     f
         yaml file to check
     """
+    loader = UniqueKeyLoader
+    loader.add_constructor("!Sweep", ParamSweepYamlLoader.sweep_manual)
+    loader.add_constructor("!SweepRange", ParamSweepYamlLoader.sweep_range)
     try:
-        conf = yaml_loader(f, loader=UniqueKeyLoader)
+        yaml_loader(f, loader=loader)
     except ValueError as e:
         # duplicate key found
         _print_with_colour(str(e), colour=Colour.GREEN)
@@ -387,7 +394,9 @@ def validate_yaml_config(yaml_file: Path, in_file: Optional[Path] = None) -> boo
     module in `httomo.yaml_templates`.
     """
     with open(yaml_file, "r") as f:
-        conf_generator: Iterator[Any] = yaml.load_all(f, Loader=yaml.FullLoader)
+        conf_generator: Iterator[Any] = yaml.load_all(
+            f, Loader=get_param_sweep_yaml_loader()
+        )
         is_yaml_ok = sanity_check(conf_generator)
 
     are_keys_duplicated = check_no_duplicated_keys(yaml_file)


Test patch:
diff --git a/tests/data/test_param_sweep_store.py b/tests/data/test_param_sweep_store.py
new file mode 100644
index 000000000..bf0b49f20
--- /dev/null
+++ b/tests/data/test_param_sweep_store.py
@@ -0,0 +1,123 @@
+import pytest
+import numpy as np
+from pytest_mock import MockerFixture
+
+from httomo.data.param_sweep_store import ParamSweepWriter
+from httomo.runner.auxiliary_data import AuxiliaryData
+from httomo.sweep_runner.param_sweep_block import ParamSweepBlock
+
+
+def make_param_sweep_writer() -> ParamSweepWriter:
+    NO_OF_SWEEPS = 5
+    return ParamSweepWriter(NO_OF_SWEEPS)
+
+
+def test_param_sweep_writer_get_no_of_sweeps():
+    writer = make_param_sweep_writer()
+    assert writer.no_of_sweeps == 5
+
+
+def test_param_sweep_writer_get_concat_dim():
+    writer = make_param_sweep_writer()
+    assert writer.concat_dim == 1
+
+
+def test_param_sweep_writer_get_single_shape_before_write_raises_error():
+    writer = make_param_sweep_writer()
+    with pytest.raises(ValueError) as e:
+        writer.single_shape
+    assert (
+        "Shape of single sweep result isn't known until the first write has occurred"
+    ) in str(e)
+
+
+def test_param_sweep_writer_get_total_shape_before_write_raises_error():
+    writer = make_param_sweep_writer()
+    with pytest.raises(ValueError) as e:
+        writer.total_shape
+    assert (
+        "Shape of full array holding sweep results isn't known until the first write "
+        "has occurred"
+    ) in str(e)
+
+
+def test_param_sweep_write_make_reader_errors_if_data_none():
+    writer = make_param_sweep_writer()
+    with pytest.raises(ValueError) as e:
+        writer.make_reader()
+
+    assert "no data has been written yet" in str(e)
+
+
+def test_param_sweep_writer_write_sweep_result_transfers_gpu_arr_to_cpu(
+    mocker: MockerFixture,
+):
+    SWEEP_RES_SHAPE = (180, 3, 160)
+    writer = make_param_sweep_writer()
+    block = ParamSweepBlock(
+        data=np.ones(SWEEP_RES_SHAPE, dtype=np.float32),
+        aux_data=AuxiliaryData(angles=np.ones(SWEEP_RES_SHAPE[0], dtype=np.float32)),
+    )
+
+    # Patch `gpu_enabled` to simulate a GPU run
+    mocker.patch("httomo.base_block.gpu_enabled", True)
+    # Spy on the `block.to_gpu()` method, in order to detect if the block's data was
+    # transferred to CPU when written to the param sweep store
+    to_cpu_spy = mocker.patch.object(block, "to_cpu")
+
+    writer.write_sweep_result(block)
+    to_cpu_spy.assert_called_once()
+
+
+def test_param_sweep_writer_reader_write_res_and_read():
+    CONCAT_DIM = 1
+    SWEEP_RES_SHAPE = (180, 3, 160)
+    NO_OF_SWEEPS = 2
+    TOTAL_SHAPE = (
+        SWEEP_RES_SHAPE[0],
+        SWEEP_RES_SHAPE[1] * NO_OF_SWEEPS,
+        SWEEP_RES_SHAPE[2],
+    )
+    writer = ParamSweepWriter(NO_OF_SWEEPS)
+
+    # Define an array that will contain data representing the fake result of two parameter
+    # sweep executions.
+    #
+    # Two blocks will then be created from the dummy data in the array, written to the param
+    # sweep store with the writer, and the result will be read from the param sweep store with
+    # the reader
+    data = np.arange(np.prod(SWEEP_RES_SHAPE) * NO_OF_SWEEPS, dtype=np.float32).reshape(
+        TOTAL_SHAPE
+    )
+
+    # Define two blocks to write to the param sweep store
+    aux_data = AuxiliaryData(np.ones(SWEEP_RES_SHAPE[0], dtype=np.float32))
+    sweep_result_1 = ParamSweepBlock(
+        data=data[:, : SWEEP_RES_SHAPE[CONCAT_DIM], :],
+        aux_data=aux_data,
+    )
+    sweep_result_2 = ParamSweepBlock(
+        data=data[:, SWEEP_RES_SHAPE[CONCAT_DIM] :, :],
+        aux_data=aux_data,
+    )
+
+    # Write two different blocks to the param sweep store, to simulate writing the results of a
+    # parameter sweep that was across two different values
+    writer.write_sweep_result(sweep_result_1)
+    writer.write_sweep_result(sweep_result_2)
+
+    # Check the reader produces a block containing the middle slices from the two parameter
+    # sweep results that were written to the parameter sweep store
+    reader = writer.make_reader()
+    block_with_middle_slices = reader.read_sweep_results()
+    SWEEP_RESULT_ONE_MID_SLICE_IDX = 1
+    SWEEP_RESULT_ONE_MID_SLICE = data[:, SWEEP_RESULT_ONE_MID_SLICE_IDX, :]
+    SWEEP_RESULT_TWO_MID_SLICE_IDX = 4
+    SWEEP_RESULT_TWO_MID_SLICE = data[:, SWEEP_RESULT_TWO_MID_SLICE_IDX, :]
+    assert block_with_middle_slices.data.shape[CONCAT_DIM] == NO_OF_SWEEPS
+    np.testing.assert_array_equal(
+        block_with_middle_slices.data[:, 0, :], SWEEP_RESULT_ONE_MID_SLICE
+    )
+    np.testing.assert_array_equal(
+        block_with_middle_slices.data[:, 1, :], SWEEP_RESULT_TWO_MID_SLICE
+    )
diff --git a/tests/runner/test_dataset_block.py b/tests/runner/test_dataset_block.py
index b04036a09..92785a774 100644
--- a/tests/runner/test_dataset_block.py
+++ b/tests/runner/test_dataset_block.py
@@ -1,12 +1,11 @@
 import math
 from typing import Tuple
+
+import pytest
 import numpy as np
-from pytest_mock import MockerFixture
+
 from httomo.runner.auxiliary_data import AuxiliaryData
 from httomo.runner.dataset import DataSetBlock
-from httomo.utils import gpu_enabled, xp
-import pytest
-
 from httomo.utils import make_3d_shape_from_shape
 
 
@@ -182,16 +181,6 @@ def test_inconsistent_global_chunk_shape_non_slice_dim(slicing_dim: int):
         )
 
 
-def test_longer_angles_length_half_projections():
-    data = np.ones((10, 10, 10), dtype=np.float32)
-    angles = np.linspace(0, math.pi, data.shape[0] // 2, dtype=np.float32)
-    block = DataSetBlock(
-        data,
-        aux_data=AuxiliaryData(angles=angles),
-    )
-    assert len(block.angles) == data.shape[0] // 2
-
-
 # chunk_index outside of chunk_shape
 @pytest.mark.parametrize(
     "block_start",
@@ -287,6 +276,8 @@ def test_inconsistent_chunk_start(chunk_start: int):
 
 
 def test_modify_data_no_shape_change():
+    # `DataSetBlock` overrides `data.setter` inherited from `BaseBlock` so should be tested,
+    # even though this test looks very similar to analagous test for `BaseBlock.data.setter`
     data = np.ones((10, 10, 10), dtype=np.float32)
     angles = np.linspace(0, math.pi, 30, dtype=np.float32)
     block = DataSetBlock(
@@ -304,6 +295,8 @@ def test_modify_data_no_shape_change():
 
 
 def test_modify_data_shape_change():
+    # `DataSetBlock` overrides `data.setter` inherited from `BaseBlock` so should be tested,
+    # even though this test looks very similar to analagous test for `BaseBlock.data.setter`
     block_start = 2
     chunk_start = 10
     data = np.ones((5, 10, 10), dtype=np.float32)
@@ -348,275 +341,3 @@ def test_modify_data_shape_change_in_slicing_dim_fails(new_size: int):
 
     with pytest.raises(ValueError):
         block.data = 2 * np.ones((new_size, 10, 10), dtype=np.float32)
-
-
-# flats / darks  get/set in the aux object + change is observable in aux (persistent)
-
-
-def test_darks_and_flats_get():
-    data = np.ones((10, 10, 10), dtype=np.float32)
-    darks = 2.0 * np.ones((3, 10, 10), dtype=np.float32)
-    flats = 3.0 * np.ones((5, 10, 10), dtype=np.float32)
-    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
-    block = DataSetBlock(
-        data=data, aux_data=AuxiliaryData(angles=angles, darks=darks, flats=flats)
-    )
-
-    np.testing.assert_array_equal(darks, block.darks)
-    np.testing.assert_array_equal(darks, block.dark)
-    np.testing.assert_array_equal(flats, block.flats)
-    np.testing.assert_array_equal(flats, block.flat)
-
-
-def test_darks_and_flats_set_same_shape():
-    data = np.ones((10, 10, 10), dtype=np.float32)
-    darks = 2.0 * np.ones((3, 10, 10), dtype=np.float32)
-    flats = 3.0 * np.ones((5, 10, 10), dtype=np.float32)
-    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
-    aux_data = AuxiliaryData(angles=angles, darks=darks, flats=flats)
-    block = DataSetBlock(data=data, aux_data=aux_data)
-
-    block.darks = block.darks * 2
-    block.flats = block.flats * 2
-
-    np.testing.assert_array_equal(darks * 2, block.darks)
-    np.testing.assert_array_equal(flats * 2, block.flats)
-    np.testing.assert_array_equal(darks * 2, aux_data.get_darks())
-    np.testing.assert_array_equal(flats * 2, aux_data.get_flats())
-
-
-def test_darks_and_flats_set_different_dtype():
-    data = np.ones((10, 10, 10), dtype=np.float32)
-    darks = 2 * np.ones((3, 10, 10), dtype=np.uint16)
-    flats = 3 * np.ones((5, 10, 10), dtype=np.uint16)
-    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
-    aux_data = AuxiliaryData(angles=angles, darks=darks, flats=flats)
-    block = DataSetBlock(data=data, aux_data=aux_data)
-
-    block.darks = block.darks.astype(np.float32)
-    block.flats = block.flats.astype(np.float32)
-
-    assert block.darks.dtype == np.float32
-    assert block.flats.dtype == np.float32
-    assert aux_data.get_darks().dtype == np.float32
-    assert aux_data.get_flats().dtype == np.float32
-
-
-def test_darks_and_flats_set_different_shape():
-    data = np.ones((10, 10, 10), dtype=np.float32)
-    darks = 2.0 * np.ones((3, 10, 10), dtype=np.float32)
-    flats = 3.0 * np.ones((5, 10, 10), dtype=np.float32)
-    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
-    aux_data = AuxiliaryData(angles=angles, darks=darks, flats=flats)
-    block = DataSetBlock(data=data, aux_data=aux_data)
-
-    block.darks = np.mean(block.darks, axis=0, keepdims=True)
-    block.flats = np.mean(block.flats, axis=0, keepdims=True)
-
-    np.testing.assert_array_equal(
-        block.darks, 2.0 * np.ones((1, 10, 10), dtype=np.float32)
-    )
-    np.testing.assert_array_equal(
-        block.dark, 2.0 * np.ones((1, 10, 10), dtype=np.float32)
-    )
-    np.testing.assert_array_equal(
-        block.flats, 3.0 * np.ones((1, 10, 10), dtype=np.float32)
-    )
-    np.testing.assert_array_equal(
-        block.flat, 3.0 * np.ones((1, 10, 10), dtype=np.float32)
-    )
-    np.testing.assert_array_equal(
-        aux_data.get_darks(), 2.0 * np.ones((1, 10, 10), dtype=np.float32)
-    )
-    np.testing.assert_array_equal(
-        aux_data.get_flats(), 3.0 * np.ones((1, 10, 10), dtype=np.float32)
-    )
-
-
-def test_darks_and_flats_set_via_alias():
-    data = np.ones((10, 10, 10), dtype=np.float32)
-    darks = 2.0 * np.ones((3, 10, 10), dtype=np.float32)
-    flats = 3.0 * np.ones((5, 10, 10), dtype=np.float32)
-    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
-    aux_data = AuxiliaryData(angles=angles, darks=darks, flats=flats)
-    block = DataSetBlock(data=data, aux_data=aux_data)
-
-    block.dark = np.mean(block.darks, axis=0, keepdims=True)
-    block.flat = np.mean(block.flats, axis=0, keepdims=True)
-
-    np.testing.assert_array_equal(
-        aux_data.get_darks(), 2.0 * np.ones((1, 10, 10), dtype=np.float32)
-    )
-    np.testing.assert_array_equal(
-        aux_data.get_flats(), 3.0 * np.ones((1, 10, 10), dtype=np.float32)
-    )
-
-
-def test_angles_set():
-    data = np.ones((10, 10, 10), dtype=np.float32)
-    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
-    aux_data = AuxiliaryData(angles=angles)
-    block = DataSetBlock(data=data, aux_data=aux_data)
-
-    block.angles = np.ones(10, dtype=np.float32)
-
-    np.testing.assert_array_equal(aux_data.get_angles(), 1.0)
-
-
-def test_angles_set_via_alias():
-    data = np.ones((10, 10, 10), dtype=np.float32)
-    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
-    aux_data = AuxiliaryData(angles=angles)
-    block = DataSetBlock(data=data, aux_data=aux_data)
-
-    block.angles_radians = np.ones(10, dtype=np.float32)
-
-    np.testing.assert_array_equal(aux_data.get_angles(), 1.0)
-
-
-def test_aux_can_drop_darks_flats():
-    # TODO: consider a separate test suite for AuxiliaryData
-    darks = 2.0 * np.ones((3, 10, 10), dtype=np.float32)
-    flats = 3.0 * np.ones((5, 10, 10), dtype=np.float32)
-    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
-    aux_data = AuxiliaryData(angles=angles, darks=darks, flats=flats)
-
-    assert aux_data.darks_dtype == np.float32
-    assert aux_data.flats_dtype == np.float32
-    assert aux_data.darks_shape == darks.shape
-    assert aux_data.flats_shape == flats.shape
-    assert aux_data.angles_dtype == np.float32
-    assert aux_data.angles_length == 10
-
-    aux_data.drop_darks_flats()
-
-    assert aux_data.get_darks() is None
-    assert aux_data.get_flats() is None
-    assert aux_data.darks_dtype is None
-    assert aux_data.flats_dtype is None
-    assert aux_data.darks_shape == (0, 0, 0)
-    assert aux_data.flats_shape == (0, 0, 0)
-    assert aux_data.angles_dtype == np.float32
-    assert aux_data.angles_length == 10
-
-
-# to_gpu, to_cpu (like DataSet) -> should not transfer flats/darks immediately
-
-
-# to_gpu when no GPU available should throw
-def test_to_gpu_fails_when_gpu_not_enabled(mocker):
-    mocker.patch("httomo.runner.dataset.gpu_enabled", False)
-    data = np.ones((10, 10, 10), dtype=np.float32)
-    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
-    block = DataSetBlock(data=data, aux_data=AuxiliaryData(angles=angles))
-    with pytest.raises(ValueError) as e:
-        block.to_gpu()
-
-    assert "no GPU available" in str(e)
-
-
-# to_gpu with enabled GPU transfers the data
-@pytest.mark.skipif(
-    not gpu_enabled or xp.cuda.runtime.getDeviceCount() == 0,
-    reason="skipped as cupy is not available",
-)
-def test_to_gpu_transfers_the_data():
-    data = np.ones((10, 10, 10), dtype=np.float32)
-    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
-    block = DataSetBlock(data=data, aux_data=AuxiliaryData(angles=angles))
-
-    block.to_gpu()
-
-    assert block.is_gpu is True
-    assert block.is_cpu is False
-    assert block.data.device == xp.cuda.Device()
-
-
-@pytest.mark.skipif(
-    not gpu_enabled or xp.cuda.runtime.getDeviceCount() == 0,
-    reason="skipped as cupy is not available",
-)
-def test_to_gpu_twice_has_no_effect():
-    data = np.ones((10, 10, 10), dtype=np.float32)
-    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
-    block = DataSetBlock(data=data, aux_data=AuxiliaryData(angles=angles))
-
-    block.to_gpu()
-    # this gives us the memory address (pointer) to the data on GPU
-    # since we want to make sure that no copy is taken on the second call
-    address = block.data.__cuda_array_interface__["data"]
-    block.to_gpu()
-
-    assert block.data.__cuda_array_interface__["data"] == address
-
-
-@pytest.mark.skipif(
-    not gpu_enabled or xp.cuda.runtime.getDeviceCount() == 0,
-    reason="skipped as cupy is not available",
-)
-def test_to_cpu_transfers_the_data_back():
-    data = np.ones((10, 10, 10), dtype=np.float32)
-    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
-    block = DataSetBlock(data=data, aux_data=AuxiliaryData(angles=angles))
-    block.to_gpu()
-
-    block.to_cpu()
-
-    assert block.is_gpu is False
-    assert block.is_cpu is True
-    assert getattr(block.data, "device", None) is None
-
-
-def test_transfer_to_cpu_with_no_gpu(mocker: MockerFixture):
-    mocker.patch("httomo.runner.dataset.gpu_enabled", False)
-    mocker.patch("httomo.runner.dataset.xp", np)
-
-    data = np.ones((10, 10, 10), dtype=np.float32)
-    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
-    block = DataSetBlock(data=data, aux_data=AuxiliaryData(angles=angles))
-
-    block.to_cpu()
-
-    assert block.is_gpu is False
-    assert block.is_cpu is True
-    assert getattr(block.data, "device", None) is None
-
-
-def test_to_cpu_twice_has_no_effect():
-    data = np.ones((10, 10, 10), dtype=np.float32)
-    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
-    block = DataSetBlock(data=data, aux_data=AuxiliaryData(angles=angles))
-
-    # this gives us the memory address (pointer) to the data on CPU
-    # since we want to make sure that no copy is taken on the second call
-    address = block.data.__array_interface__["data"]
-    block.to_cpu()
-
-    assert block.data.__array_interface__["data"] == address
-
-
-# flats / darks should be returned on the same devices as where `data` is
-@pytest.mark.skipif(
-    not gpu_enabled or xp.cuda.runtime.getDeviceCount() == 0,
-    reason="skipped as cupy is not available",
-)
-def test_returns_flats_on_gpu_when_the_data_is_there():
-    data = np.ones((10, 10, 10), dtype=np.float32)
-    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
-    darks = 2.0 * np.ones((3, 10, 10), dtype=np.float32)
-    flats = 3.0 * np.ones((5, 10, 10), dtype=np.float32)
-    aux_data = AuxiliaryData(angles=angles, darks=darks, flats=flats)
-    block = DataSetBlock(data=data, aux_data=aux_data)
-
-    block.to_gpu()
-
-    assert block.darks.device == xp.cuda.Device()
-    assert block.flats.device == xp.cuda.Device()
-
-
-def test_attributes_array(dummy_block: DataSetBlock):
-    expected = set(
-        ["data", "flats", "darks", "angles", "angles_radians", "dark", "flat"]
-    )
-    actual = set(dir(dummy_block))
-    assert actual == expected
diff --git a/tests/samples/pipeline_template_examples/parameter-sweep-cor.yaml b/tests/samples/pipeline_template_examples/parameter-sweep-cor.yaml
new file mode 100644
index 000000000..4dde65fab
--- /dev/null
+++ b/tests/samples/pipeline_template_examples/parameter-sweep-cor.yaml
@@ -0,0 +1,55 @@
+- method: standard_tomo
+  module_path: httomo.data.hdf.loaders
+  parameters:
+    data_path: entry1/tomo_entry/data/data
+    image_key_path: entry1/tomo_entry/instrument/detector/image_key
+    rotation_angles:
+      data_path: /entry1/tomo_entry/data/rotation_angle
+    preview:
+      detector_y:
+        start: 100
+        stop: 107
+- method: normalize
+  module_path: httomolibgpu.prep.normalize
+  parameters:
+    cutoff: 10.0
+    minus_log: true
+    nonnegativity: false
+    remove_nans: false
+- method: paganin_filter_tomopy
+  module_path: httomolibgpu.prep.phase
+  parameters:
+    pixel_size: 0.0001
+    dist: 50.0
+    energy: 53.0
+    alpha: 0.001
+- method: remove_all_stripe
+  module_path: httomolibgpu.prep.stripe
+  parameters:
+    snr: 3.0
+    la_size: 61
+    sm_size: 21
+    dim: 1
+- method: FBP
+  module_path: httomolibgpu.recon.algorithm
+  save_result: False
+  parameters:
+    center: !SweepRange
+      start: 1000
+      stop: 1010
+      step: 1
+    filter_freq_cutoff: 0.6
+    recon_size: null
+    recon_mask_radius: null
+- method: save_to_images
+  module_path: httomolib.misc.images
+  parameters:
+    subfolder_name: images
+    axis: 1
+    file_format: tif
+    bits: 8
+    perc_range_min: 0.0
+    perc_range_max: 100.0
+    jpeg_quality: 95
+    offset: 0
+    asynchronous: true
diff --git a/tests/samples/pipeline_template_examples/testing/sweep_manual.py b/tests/samples/pipeline_template_examples/testing/sweep_manual.py
new file mode 100644
index 000000000..31e2eadd9
--- /dev/null
+++ b/tests/samples/pipeline_template_examples/testing/sweep_manual.py
@@ -0,0 +1,34 @@
+from httomo.ui_layer import PipelineConfig
+
+
+def methods_to_list() -> PipelineConfig:
+    full_pipeline_list = []
+    loader = {
+        "method": "standard_tomo",
+        "module_path": "httomo.data.hdf.loaders",
+        "parameters": {
+            "data_path": "entry1/tomo_entry/data/data",
+            "image_key_path": "entry1/tomo_entry/instrument/detector/image_key",
+            "rotation_angles": {"data_path": "/entry1/tomo_entry/data/rotation_angle"},
+        },
+    }
+    full_pipeline_list.append(loader)
+    method1 = {
+        "method": "normalize",
+        "module_path": "tomopy.prep.normalize",
+        "parameters": {
+            "cutoff": None,
+        },
+    }
+    full_pipeline_list.append(method1)
+    sweep_method = {
+        "method": "median_filter",
+        "module_path": "tomopy.misc.corr",
+        "parameters": {
+            "size": (3, 5),
+            "axis": 0,
+        },
+    }
+    full_pipeline_list.append(sweep_method)
+
+    return full_pipeline_list
diff --git a/tests/samples/pipeline_template_examples/testing/sweep_testing_pipeline.yaml b/tests/samples/pipeline_template_examples/testing/sweep_manual.yaml
similarity index 66%
rename from tests/samples/pipeline_template_examples/testing/sweep_testing_pipeline.yaml
rename to tests/samples/pipeline_template_examples/testing/sweep_manual.yaml
index e673f2fd3..f73df7fdf 100644
--- a/tests/samples/pipeline_template_examples/testing/sweep_testing_pipeline.yaml
+++ b/tests/samples/pipeline_template_examples/testing/sweep_manual.yaml
@@ -1,24 +1,23 @@
 - method: standard_tomo
   module_path: httomo.data.hdf.loaders
   parameters:
-    name: tomo
     data_path: entry1/tomo_entry/data/data
     image_key_path: entry1/tomo_entry/instrument/detector/image_key
     rotation_angles:
       data_path: /entry1/tomo_entry/data/rotation_angle
-    dimension: 1
-    pad: 0
-- tomopy.misc.corr:
-    median_filter:
-      data_in: tomo
-      data_out: tomo
-      size: !Sweep
-        - 3
-        - 5
-      axis: 0
+    preview:
+      detector_y:
+        start: 10
+        stop: 17
 - method: normalize
   module_path: tomopy.prep.normalize
   parameters:
     cutoff: 10.0
     averaging: mean
-
+- method: median_filter
+  module_path: tomopy.misc.corr
+  parameters:
+    size: !Sweep
+      - 3
+      - 5
+    axis: 0
diff --git a/tests/samples/pipeline_template_examples/testing/sweep_range.yaml b/tests/samples/pipeline_template_examples/testing/sweep_range.yaml
new file mode 100644
index 000000000..6cf231dc8
--- /dev/null
+++ b/tests/samples/pipeline_template_examples/testing/sweep_range.yaml
@@ -0,0 +1,24 @@
+- method: standard_tomo
+  module_path: httomo.data.hdf.loaders
+  parameters:
+    data_path: entry1/tomo_entry/data/data
+    image_key_path: entry1/tomo_entry/instrument/detector/image_key
+    rotation_angles:
+      data_path: /entry1/tomo_entry/data/rotation_angle
+    preview:
+      detector_y:
+        start: 10
+        stop: 17
+- method: normalize
+  module_path: tomopy.prep.normalize
+  parameters:
+    cutoff: 10.0
+    averaging: mean
+- method: median_filter
+  module_path: tomopy.misc.corr
+  parameters:
+    size: !SweepRange
+      start: 3
+      stop:  13
+      step: 1
+    axis: 0
diff --git a/tests/sweep_runner/test_param_sweep_runner.py b/tests/sweep_runner/test_param_sweep_runner.py
new file mode 100644
index 000000000..125df5b65
--- /dev/null
+++ b/tests/sweep_runner/test_param_sweep_runner.py
@@ -0,0 +1,551 @@
+import pytest
+from pytest_mock import MockerFixture
+
+import numpy as np
+from mpi4py import MPI
+
+from httomo.method_wrappers import make_method_wrapper
+from httomo.runner.auxiliary_data import AuxiliaryData
+from httomo.runner.dataset import DataSetBlock
+from httomo.runner.pipeline import Pipeline
+from httomo.sweep_runner.param_sweep_runner import ParamSweepRunner
+from httomo.sweep_runner.side_output_manager import SideOutputManager
+from httomo.sweep_runner.stages import NonSweepStage, Stages, SweepStage
+from tests.testing_utils import make_mock_repo, make_test_loader, make_test_method
+
+
+def test_raises_error_if_no_sweep_detected(mocker: MockerFixture):
+    loader = make_test_loader(mocker)
+    method = make_test_method(mocker)
+    pipeline = Pipeline(loader=loader, methods=[method])
+
+    with pytest.raises(ValueError) as e:
+        _ = ParamSweepRunner(pipeline)
+
+    assert "No parameter sweep detected in pipeline" in str(e)
+
+
+def test_raises_error_if_multiple_sweeps_detected(mocker: MockerFixture):
+    loader = make_test_loader(mocker)
+    m1 = make_test_method(mocker, param_1=(0, 1, 2, 3))
+    m2 = make_test_method(mocker, param_2=(4, 5, 6, 7))
+    pipeline = Pipeline(loader=loader, methods=[m1, m2])
+
+    with pytest.raises(ValueError) as e:
+        _ = ParamSweepRunner(pipeline)
+
+    assert "Parameter sweep over more than one parameter detected in pipeline" in str(e)
+
+
+def test_determine_stages_produces_correct_stages(mocker: MockerFixture):
+    SWEEP_VALS = (0, 1, 2, 3)
+    loader = make_test_loader(mocker)
+    m1 = make_test_method(mocker)
+    sweep_wrapper = make_test_method(mocker, param_1=SWEEP_VALS)
+    m3 = make_test_method(mocker)
+    pipeline = Pipeline(loader=loader, methods=[m1, sweep_wrapper, m3])
+    expected_stages = Stages(
+        before_sweep=NonSweepStage([m1]),
+        sweep=SweepStage(method=sweep_wrapper, param_name="param_1", values=SWEEP_VALS),
+        after_sweep=NonSweepStage([m3]),
+    )
+    runner = ParamSweepRunner(pipeline)
+    stages = runner.determine_stages()
+    assert stages == expected_stages
+
+
+def test_without_prepare_block_property_raises_error(mocker: MockerFixture):
+    loader = make_test_loader(mocker)
+    m1 = make_test_method(mocker)
+    sweep_wrapper = make_test_method(mocker, param_1=(0,))
+    pipeline = Pipeline(loader=loader, methods=[m1, sweep_wrapper])
+    runner = ParamSweepRunner(pipeline)
+    with pytest.raises(ValueError) as e:
+        runner.block
+    assert "Block from input data has not yet been loaded" in str(e)
+
+
+def test_after_prepare_block_attr_contains_data(mocker: MockerFixture):
+    GLOBAL_SHAPE = PREVIEWED_SLICES_SHAPE = (180, 3, 160)
+    data = np.arange(np.prod(PREVIEWED_SLICES_SHAPE), dtype=np.uint16).reshape(
+        PREVIEWED_SLICES_SHAPE
+    )
+    aux_data = AuxiliaryData(np.ones(PREVIEWED_SLICES_SHAPE[0], dtype=np.float32))
+    block = DataSetBlock(
+        data=data,
+        aux_data=aux_data,
+        slicing_dim=0,
+        global_shape=GLOBAL_SHAPE,
+        chunk_start=0,
+        chunk_shape=GLOBAL_SHAPE,
+        block_start=0,
+    )
+    loader = make_test_loader(mocker, block=block)
+    m1 = make_test_method(mocker)
+    sweep_wrapper = make_test_method(mocker, param_1=(0,))
+    pipeline = Pipeline(loader=loader, methods=[m1, sweep_wrapper])
+    runner = ParamSweepRunner(pipeline)
+    runner.prepare()
+    assert runner.block is not None
+    np.testing.assert_array_equal(runner.block.data, data)
+
+
+def tests_prepare_raises_error_if_too_many_sino_slices(mocker: MockerFixture):
+    TOO_MANY_SINO_SLICES = 10
+    GLOBAL_SHAPE = PREVIEWED_SLICES_SHAPE = (180, TOO_MANY_SINO_SLICES, 160)
+    data = np.arange(np.prod(PREVIEWED_SLICES_SHAPE), dtype=np.uint16).reshape(
+        PREVIEWED_SLICES_SHAPE
+    )
+    aux_data = AuxiliaryData(np.ones(PREVIEWED_SLICES_SHAPE[0], dtype=np.float32))
+    block = DataSetBlock(
+        data=data,
+        aux_data=aux_data,
+        slicing_dim=0,
+        global_shape=GLOBAL_SHAPE,
+        chunk_start=0,
+        chunk_shape=GLOBAL_SHAPE,
+        block_start=0,
+    )
+    loader = make_test_loader(mocker, block=block)
+    m1 = make_test_method(mocker)
+    sweep_wrapper = make_test_method(mocker, param_1=(0,))
+    pipeline = Pipeline(loader=loader, methods=[m1, sweep_wrapper])
+    runner = ParamSweepRunner(pipeline)
+
+    with pytest.raises(ValueError) as e:
+        runner.prepare()
+
+    err_str = (
+        "Parameter sweep runs support input data containing <= 7 sinogram slices, "
+        "input data contains 10 slices"
+    )
+    assert err_str in str(e)
+
+
+@pytest.mark.parametrize("non_sweep_stage", ["before", "after"])
+def test_execute_non_sweep_stage_modifies_block(
+    mocker: MockerFixture,
+    non_sweep_stage: str,
+):
+    # Define dummy block for loader to provide
+    GLOBAL_SHAPE = PREVIEWED_SLICES_SHAPE = (180, 3, 160)
+    data = np.ones(PREVIEWED_SLICES_SHAPE, dtype=np.float32)
+    aux_data = AuxiliaryData(np.ones(PREVIEWED_SLICES_SHAPE[0], dtype=np.float32))
+    block = DataSetBlock(
+        data=data,
+        aux_data=aux_data,
+        slicing_dim=0,
+        global_shape=GLOBAL_SHAPE,
+        chunk_start=0,
+        chunk_shape=GLOBAL_SHAPE,
+        block_start=0,
+    )
+    loader = make_test_loader(mocker, block=block)
+
+    # Define two dummy methods to be in the "before sweep" stage in the runner
+    def dummy_method_1(block):
+        block.data = block.data * 2
+        return block
+
+    def dummy_method_2(block):
+        block.data = block.data * 3
+        return block
+
+    # Patch `the `execute() method in the mock method wrapper objects so then the functions
+    # that are used for the two methods executed in the stage are the two dummy methods defined
+    # above
+    m1 = make_test_method(mocker=mocker, method_name="method_1")
+    mocker.patch.object(target=m1, attribute="execute", side_effect=dummy_method_1)
+    m2 = make_test_method(mocker=mocker, method_name="method_2")
+    mocker.patch.object(target=m2, attribute="execute", side_effect=dummy_method_2)
+
+    # Define sweep wrapper to to provide the runner the ability to distinguish between "before
+    # sweep" and "after sweep" stages
+    sweep_wrapper = make_test_method(mocker, param_1=(0,))
+
+    # Define pipeline object that produces the "before sweep" and "after sweep" stages desired
+    # for this parametrised test
+    if non_sweep_stage == "before":
+        pipeline = Pipeline(loader=loader, methods=[m1, m2, sweep_wrapper])
+    else:
+        pipeline = Pipeline(loader=loader, methods=[sweep_wrapper, m1, m2])
+
+    runner = ParamSweepRunner(pipeline)
+    runner.prepare()
+    if non_sweep_stage == "before":
+        runner.execute_before_sweep()
+    else:
+        runner.execute_after_sweep()
+
+    # Inspect the block data after the non-sweep stage has completed, asserting that the data
+    # reflects what the combination of two dummy methods in that stage should produce
+    assert runner.block.data.shape == PREVIEWED_SLICES_SHAPE
+    expected_block_data = data * 2 * 3
+    np.testing.assert_array_equal(runner.block.data, expected_block_data)
+
+
+@pytest.mark.parametrize("non_sweep_stage", ["before", "after"])
+def test_execute_non_sweep_stage_method_output_updates_side_outputs(
+    mocker: MockerFixture,
+    non_sweep_stage: str,
+):
+    # Define dummy block for loader to provide
+    GLOBAL_SHAPE = PREVIEWED_SLICES_SHAPE = (180, 3, 160)
+    data = np.ones(PREVIEWED_SLICES_SHAPE, dtype=np.float32)
+    aux_data = AuxiliaryData(np.ones(PREVIEWED_SLICES_SHAPE[0], dtype=np.float32))
+    block = DataSetBlock(
+        data=data,
+        aux_data=aux_data,
+        slicing_dim=0,
+        global_shape=GLOBAL_SHAPE,
+        chunk_start=0,
+        chunk_shape=GLOBAL_SHAPE,
+        block_start=0,
+    )
+    loader = make_test_loader(mocker, block=block)
+
+    # Define side output that dummy method in stage will produce
+    SIDE_OUTPUT_LABEL = "some_side_output"
+    side_outputs = {SIDE_OUTPUT_LABEL: 5}
+
+    # Define mock method wrapper to produce the above side output
+    mock_wrapper = make_test_method(mocker)
+    mocker.patch.object(
+        target=mock_wrapper, attribute="get_side_output", return_value=side_outputs
+    )
+
+    # Define sweep wrapper to to provide the runner the ability to distinguish between "before
+    # sweep" and "after sweep" stages
+    sweep_wrapper = make_test_method(mocker, param_1=(0,))
+
+    # Define pipeline object that produces the "before sweep" and "after sweep" stages desired
+    # for this parametrised test
+    if non_sweep_stage == "before":
+        pipeline = Pipeline(loader=loader, methods=[mock_wrapper, sweep_wrapper])
+    else:
+        pipeline = Pipeline(loader=loader, methods=[sweep_wrapper, mock_wrapper])
+
+    # Pass in an empty side output manager object into the runner. The execution of the method
+    # should add the side output produced by it to the side output manager.
+    side_output_manager = SideOutputManager()
+    runner = ParamSweepRunner(
+        pipeline=pipeline, side_output_manager=side_output_manager
+    )
+    runner.prepare()
+    if non_sweep_stage == "before":
+        runner.execute_before_sweep()
+    else:
+        runner.execute_after_sweep()
+
+    # Check that the side output manager has been updated with the mock method's side output
+    assert SIDE_OUTPUT_LABEL in side_output_manager.labels
+    assert side_output_manager.get(SIDE_OUTPUT_LABEL) == side_outputs[SIDE_OUTPUT_LABEL]
+
+
+@pytest.mark.parametrize("non_sweep_stage", ["before", "after"])
+def test_execute_non_sweep_stage_method_params_updated_from_side_outputs(
+    mocker: MockerFixture,
+    non_sweep_stage: str,
+):
+    # Define dummy block for loader to provide
+    GLOBAL_SHAPE = PREVIEWED_SLICES_SHAPE = (180, 3, 160)
+    data = np.ones(PREVIEWED_SLICES_SHAPE, dtype=np.float32)
+    aux_data = AuxiliaryData(np.ones(PREVIEWED_SLICES_SHAPE[0], dtype=np.float32))
+    block = DataSetBlock(
+        data=data,
+        aux_data=aux_data,
+        slicing_dim=0,
+        global_shape=GLOBAL_SHAPE,
+        chunk_start=0,
+        chunk_shape=GLOBAL_SHAPE,
+        block_start=0,
+    )
+    loader = make_test_loader(mocker, block=block)
+
+    # Define side output that dummy method in stage will require
+    SIDE_OUTPUT_LABEL = "some_side_output"
+    side_outputs = {SIDE_OUTPUT_LABEL: 5}
+    side_output_manager = SideOutputManager()
+    side_output_manager.append(side_outputs)
+
+    # Define mock module + mock method function that will be imported by method wrapper.
+    #
+    # The mock method function asserts that the param value it's given is the value of the side
+    # output that it requires.
+    class FakeModule:
+        def method_1(data, some_side_output) -> np.ndarray:  # type: ignore
+            assert some_side_output == side_outputs[SIDE_OUTPUT_LABEL]
+            return np.empty(PREVIEWED_SLICES_SHAPE, dtype=np.float32)
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    method_wrapper = make_method_wrapper(
+        method_repository=make_mock_repo(mocker),
+        module_path="",
+        method_name="method_1",
+        comm=MPI.COMM_WORLD,
+    )
+
+    # Define sweep wrapper to to provide the runner the ability to distinguish between "before
+    # sweep" and "after sweep" stages
+    sweep_wrapper = make_test_method(mocker, param_1=(0,))
+
+    # Define pipeline object that produces the "before sweep" and "after sweep" stages desired
+    # for this parametrised test
+    if non_sweep_stage == "before":
+        pipeline = Pipeline(loader=loader, methods=[method_wrapper, sweep_wrapper])
+    else:
+        pipeline = Pipeline(loader=loader, methods=[sweep_wrapper, method_wrapper])
+
+    runner = ParamSweepRunner(
+        pipeline=pipeline, side_output_manager=side_output_manager
+    )
+    runner.prepare()
+    if non_sweep_stage == "before":
+        runner.execute_before_sweep()
+    else:
+        runner.execute_after_sweep()
+
+
+def test_execute_sweep_stage_modifies_block(mocker: MockerFixture):
+    # Define dummy block for loader to provide
+    GLOBAL_SHAPE = PREVIEWED_SLICES_SHAPE = (180, 3, 160)
+    data = np.ones(PREVIEWED_SLICES_SHAPE, dtype=np.float32)
+    aux_data = AuxiliaryData(np.ones(PREVIEWED_SLICES_SHAPE[0], dtype=np.float32))
+    block = DataSetBlock(
+        data=data,
+        aux_data=aux_data,
+        slicing_dim=0,
+        global_shape=GLOBAL_SHAPE,
+        chunk_start=0,
+        chunk_shape=GLOBAL_SHAPE,
+        block_start=0,
+    )
+    loader = make_test_loader(mocker, block=block)
+
+    # Define a dummy method function that the method wrapper will be patched to import. When
+    # executing the sweep stage, different values will be passed to it, which should influence
+    # the data in the block produced each time the method wrapper is executed in the
+    # sweep.
+    class FakeModule:
+        def sweep_method(data: np.ndarray, param_1: int):  # type: ignore
+            return data * param_1
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+
+    # Create sweep method wrapper, passing the sweep values for the param to sweep over
+    NO_OF_SWEEPS = 4
+    SWEEP_VALUES = (2, 3, 5, 7)
+    sweep_method_wrapper = make_method_wrapper(
+        method_repository=make_mock_repo(mocker),
+        module_path="mocked_module_path.corr",
+        method_name="sweep_method",
+        comm=MPI.COMM_WORLD,
+        param_1=SWEEP_VALUES,
+    )
+
+    # Define pipeline, stages, runner objects, and execute the methods in the sweep stage
+    pipeline = Pipeline(loader=loader, methods=[sweep_method_wrapper])
+    runner = ParamSweepRunner(pipeline)
+    runner.prepare()
+    runner.execute_sweep()
+
+    EXPECTED_BLOCK_SHAPE = (
+        PREVIEWED_SLICES_SHAPE[0],
+        NO_OF_SWEEPS,
+        PREVIEWED_SLICES_SHAPE[2],
+    )
+    assert runner.block.data.shape == EXPECTED_BLOCK_SHAPE
+    expected_data = np.ones(EXPECTED_BLOCK_SHAPE, dtype=np.float32)
+    for sino_slice_idx, multiplier in enumerate(SWEEP_VALUES):
+        expected_data[:, sino_slice_idx, :] *= multiplier
+    np.testing.assert_array_equal(runner.block.data, expected_data)
+
+
+def test_execute_modifies_block(mocker: MockerFixture):
+    # Define dummy block for loader to provide
+    GLOBAL_SHAPE = PREVIEWED_SLICES_SHAPE = (180, 3, 160)
+    data = np.ones(PREVIEWED_SLICES_SHAPE, dtype=np.float32)
+    aux_data = AuxiliaryData(np.ones(PREVIEWED_SLICES_SHAPE[0], dtype=np.float32))
+    block = DataSetBlock(
+        data=data,
+        aux_data=aux_data,
+        slicing_dim=0,
+        global_shape=GLOBAL_SHAPE,
+        chunk_start=0,
+        chunk_shape=GLOBAL_SHAPE,
+        block_start=0,
+    )
+    loader = make_test_loader(mocker, block=block)
+
+    # For non-sweep stages, the `execute()` method on the mock wrappers will be patched. For
+    # the sweep stage, the method function that the wrapper imports will be patched.
+    #
+    # This is because for non-sweep stages, all that is needed is to see the block data change.
+    # For the sweep stage, it needs to be confirmed that the sweep values are actually being
+    # used in the execution of the sweep. The easiest way to see this is for the parameter
+    # value to be used as a simple multiplier in the method function that the wrapper executes,
+    # which would cause the block data to change according to the given sweep values.
+
+    # Define two dummy methods to be in the before and sweep stages in the runner
+    def dummy_method_1(block):
+        block.data = block.data * 2
+        return block
+
+    def dummy_method_2(block):
+        block.data = block.data * 3
+        return block
+
+    # Define a dummy method function that the method wrapper will be patched to import. When
+    # executing the sweep stage, different values will be passed to it, which should influence
+    # the data in the block produced each time the method wrapper is executed in the
+    # sweep.
+    class FakeModule:
+        def sweep_method(data: np.ndarray, param_1: int):  # type: ignore
+            return data * param_1
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+
+    # Create sweep method wrapper, passing the sweep values for the param to sweep over
+    NO_OF_SWEEPS = 4
+    SWEEP_VALUES = (2, 3, 5, 7)
+    sweep_method_wrapper = make_method_wrapper(
+        method_repository=make_mock_repo(mocker),
+        module_path="mocked_module_path.corr",
+        method_name="sweep_method",
+        comm=MPI.COMM_WORLD,
+        param_1=SWEEP_VALUES,
+    )
+
+    # Create mock method wrapper objects to be used in the non-sweep stages and patch the
+    # `execute()` method on all of them to use the dummy method functions defined above
+    #
+    # "Before sweep" stage method wrappers (2)
+    m1 = make_test_method(mocker=mocker, method_name="method_1")
+    mocker.patch.object(target=m1, attribute="execute", side_effect=dummy_method_1)
+    m2 = make_test_method(mocker=mocker, method_name="method_2")
+    mocker.patch.object(target=m2, attribute="execute", side_effect=dummy_method_2)
+    # "After sweep" stage method wrappers (2)
+    m4 = make_test_method(mocker=mocker, method_name="method_4")
+    mocker.patch.object(target=m4, attribute="execute", side_effect=dummy_method_1)
+    m5 = make_test_method(mocker=mocker, method_name="method_5")
+    mocker.patch.object(target=m5, attribute="execute", side_effect=dummy_method_2)
+
+    # Define pipeline, stages, runner objects, and execute the methods in the sweep stage
+    pipeline = Pipeline(loader=loader, methods=[m1, m2, sweep_method_wrapper, m4, m5])
+    runner = ParamSweepRunner(pipeline)
+    runner.execute()
+
+    NO_OF_SWEEPS = 4
+    EXPECTED_BLOCK_SHAPE = (
+        PREVIEWED_SLICES_SHAPE[0],
+        NO_OF_SWEEPS,
+        PREVIEWED_SLICES_SHAPE[2],
+    )
+    assert runner.block.data.shape == EXPECTED_BLOCK_SHAPE
+    BEFORE_SWEEP_MULT_FACTOR = AFTER_SWEEP_MULT_FACTOR = 2 * 3
+    NON_SWEEP_STAGES_MULT_FACTOR = BEFORE_SWEEP_MULT_FACTOR * AFTER_SWEEP_MULT_FACTOR
+    expected_data = np.ones(EXPECTED_BLOCK_SHAPE, dtype=np.float32)
+    for sino_slice_idx, multiplier in enumerate(SWEEP_VALUES):
+        expected_data[:, sino_slice_idx, :] *= NON_SWEEP_STAGES_MULT_FACTOR * multiplier
+    np.testing.assert_array_equal(runner.block.data, expected_data)
+
+
+def test_execute_sweep_stage_method_params_updated_from_side_outputs(
+    mocker: MockerFixture,
+):
+    # Define dummy block for loader to provide
+    GLOBAL_SHAPE = PREVIEWED_SLICES_SHAPE = (180, 3, 160)
+    data = np.ones(PREVIEWED_SLICES_SHAPE, dtype=np.float32)
+    aux_data = AuxiliaryData(np.ones(PREVIEWED_SLICES_SHAPE[0], dtype=np.float32))
+    block = DataSetBlock(
+        data=data,
+        aux_data=aux_data,
+        slicing_dim=0,
+        global_shape=GLOBAL_SHAPE,
+        chunk_start=0,
+        chunk_shape=GLOBAL_SHAPE,
+        block_start=0,
+    )
+    loader = make_test_loader(mocker, block=block)
+
+    # Define side output that dummy method in stage will require
+    SIDE_OUTPUT_LABEL = "some_side_output"
+    side_outputs = {SIDE_OUTPUT_LABEL: 5}
+    side_output_manager = SideOutputManager()
+    side_output_manager.append(side_outputs)
+
+    # Define mock module + mock method function that will be imported by the method wrapper
+    # that represents the parameter sweep.
+    #
+    # The mock method function asserts that the param value it's given is the value of the side
+    # output that it requires.
+    class FakeModule:
+        def sweep_method(data, param_1, some_side_output) -> np.ndarray:  # type: ignore
+            assert some_side_output == side_outputs[SIDE_OUTPUT_LABEL]
+            return np.empty(PREVIEWED_SLICES_SHAPE, dtype=np.float32)
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+
+    # Create sweep method wrapper, passing the sweep values for the param to sweep over
+    SWEEP_VALUES = (2, 3, 5, 7)
+    sweep_method_wrapper = make_method_wrapper(
+        method_repository=make_mock_repo(mocker),
+        module_path="mocked_module_path.corr",
+        method_name="sweep_method",
+        comm=MPI.COMM_WORLD,
+        param_1=SWEEP_VALUES,
+    )
+
+    # Define pipeline, stages, runner objects, and execute the methods in the sweep stage
+    pipeline = Pipeline(loader=loader, methods=[sweep_method_wrapper])
+    runner = ParamSweepRunner(
+        pipeline=pipeline, side_output_manager=side_output_manager
+    )
+    runner.prepare()
+    runner.execute_sweep()
+
+
+def test_execute_sweep_stage_method_produces_side_output_raises_error(
+    mocker: MockerFixture,
+):
+    # Define dummy block for loader to provide
+    GLOBAL_SHAPE = PREVIEWED_SLICES_SHAPE = (180, 3, 160)
+    data = np.ones(PREVIEWED_SLICES_SHAPE, dtype=np.float32)
+    aux_data = AuxiliaryData(np.ones(PREVIEWED_SLICES_SHAPE[0], dtype=np.float32))
+    block = DataSetBlock(
+        data=data,
+        aux_data=aux_data,
+        slicing_dim=0,
+        global_shape=GLOBAL_SHAPE,
+        chunk_start=0,
+        chunk_shape=GLOBAL_SHAPE,
+        block_start=0,
+    )
+    loader = make_test_loader(mocker, block=block)
+
+    # Define side output that dummy method in sweep stage will produce
+    SIDE_OUTPUT_LABEL = "some_side_output"
+    side_outputs = {SIDE_OUTPUT_LABEL: 5}
+
+    # Define mock method wrapper to produce the above side output in the sweep stage
+    #
+    # NOTE: Even though a sweep stage should generally have more than one sweep value in it,
+    # this test is for checking that a wrapper execution in the sweep stage producing a side
+    # output causes an error to be raised. If even one method wrapper execution in the sweep
+    # stage produces a side output, an error should be raised, so only one sweep value in the
+    # sweep stage is needed to fulfill the purpose of the test.
+    mock_wrapper = make_test_method(mocker, param_1=(1,))
+    mocker.patch.object(
+        target=mock_wrapper, attribute="get_side_output", return_value=side_outputs
+    )
+
+    # Define pipeline, stages, runner objects, and execute the methods in the sweep stage
+    pipeline = Pipeline(loader=loader, methods=[mock_wrapper])
+    runner = ParamSweepRunner(pipeline)
+    runner.prepare()
+
+    with pytest.raises(ValueError) as e:
+        runner.execute_sweep()
+    assert "Producing a side output is not supported in parameter sweep methods" in str(
+        e
+    )
diff --git a/tests/sweep_runner/test_side_output_manager.py b/tests/sweep_runner/test_side_output_manager.py
new file mode 100644
index 000000000..db6cd6c0e
--- /dev/null
+++ b/tests/sweep_runner/test_side_output_manager.py
@@ -0,0 +1,41 @@
+from pytest_mock import MockerFixture
+
+from httomo.sweep_runner.side_output_manager import SideOutputManager
+from tests.testing_utils import make_test_method
+
+
+def test_no_labels_after_creation():
+    side_output_manager = SideOutputManager()
+    assert len(side_output_manager.labels) == 0
+
+
+def test_append_side_outputs():
+    side_outputs = {"centre_of_rotation": 100.5}
+    side_output_manager = SideOutputManager()
+    side_output_manager.append(side_outputs)
+    assert side_output_manager.labels == ["centre_of_rotation"]
+    assert (
+        side_output_manager.get("centre_of_rotation")
+        == side_outputs["centre_of_rotation"]
+    )
+
+
+def test_update_method_with_side_output(mocker: MockerFixture):
+    # Define single side output and create side output manager
+    SIDE_OUTPUT_LABEL = "centre_of_rotation"
+    side_outputs = {SIDE_OUTPUT_LABEL: 100.5}
+    side_output_manager = SideOutputManager()
+    side_output_manager.append(side_outputs)
+
+    # Define mock method wrapper which requires the side output stored in the side output
+    # manager
+    method = make_test_method(mocker)
+    mocker.patch.object(method, "parameters", [SIDE_OUTPUT_LABEL])
+    setitem_mock = mocker.patch.object(method, "__setitem__")
+
+    # Update method wrapper param to contain value of side output and assert that the wrapper's
+    # relevant param was attempted to be updated
+    side_output_manager.update_params(method)
+    setitem_mock.assert_called_once_with(
+        SIDE_OUTPUT_LABEL, side_outputs[SIDE_OUTPUT_LABEL]
+    )
diff --git a/tests/test_base_block.py b/tests/test_base_block.py
new file mode 100644
index 000000000..6949c22b8
--- /dev/null
+++ b/tests/test_base_block.py
@@ -0,0 +1,313 @@
+import math
+
+import pytest
+import numpy as np
+from pytest_mock import MockerFixture
+
+from httomo.base_block import BaseBlock
+from httomo.runner.auxiliary_data import AuxiliaryData
+from httomo.utils import gpu_enabled, xp
+
+
+def test_longer_angles_length_half_projections():
+    data = np.ones((10, 10, 10), dtype=np.float32)
+    angles = np.linspace(0, math.pi, data.shape[0] // 2, dtype=np.float32)
+    block = BaseBlock(data, aux_data=AuxiliaryData(angles=angles))
+    assert len(block.angles) == data.shape[0] // 2
+
+
+def test_modify_data_no_shape_change():
+    data = np.ones((10, 10, 10), dtype=np.float32)
+    angles = np.linspace(0, math.pi, 30, dtype=np.float32)
+    block = BaseBlock(
+        data=data,
+        aux_data=AuxiliaryData(angles=angles),
+    )
+
+    block.data = 2 * data
+
+    np.testing.assert_array_equal(block.data, 2 * data)
+
+
+def test_modify_data_shape_change():
+    data = np.ones((5, 10, 10), dtype=np.float32)
+    global_shape = (30, 10, 10)
+    angles = np.linspace(0, math.pi, global_shape[0], dtype=np.float32)
+    block = BaseBlock(data=data, aux_data=AuxiliaryData(angles=angles))
+
+    block.data = 2 * np.ones((5, 30, 20), dtype=np.float32)
+
+    np.testing.assert_array_equal(block.data, 2.0)
+    assert block.shape == (5, 30, 20)
+
+
+# flats / darks  get/set in the aux object + change is observable in aux (persistent)
+
+
+def test_darks_and_flats_get():
+    data = np.ones((10, 10, 10), dtype=np.float32)
+    darks = 2.0 * np.ones((3, 10, 10), dtype=np.float32)
+    flats = 3.0 * np.ones((5, 10, 10), dtype=np.float32)
+    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
+    block = BaseBlock(
+        data=data, aux_data=AuxiliaryData(angles=angles, darks=darks, flats=flats)
+    )
+
+    np.testing.assert_array_equal(darks, block.darks)
+    np.testing.assert_array_equal(darks, block.dark)
+    np.testing.assert_array_equal(flats, block.flats)
+    np.testing.assert_array_equal(flats, block.flat)
+
+
+def test_darks_and_flats_set_same_shape():
+    data = np.ones((10, 10, 10), dtype=np.float32)
+    darks = 2.0 * np.ones((3, 10, 10), dtype=np.float32)
+    flats = 3.0 * np.ones((5, 10, 10), dtype=np.float32)
+    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
+    aux_data = AuxiliaryData(angles=angles, darks=darks, flats=flats)
+    block = BaseBlock(data=data, aux_data=aux_data)
+
+    block.darks = block.darks * 2
+    block.flats = block.flats * 2
+
+    np.testing.assert_array_equal(darks * 2, block.darks)
+    np.testing.assert_array_equal(flats * 2, block.flats)
+    np.testing.assert_array_equal(darks * 2, aux_data.get_darks())
+    np.testing.assert_array_equal(flats * 2, aux_data.get_flats())
+
+
+def test_darks_and_flats_set_different_dtype():
+    data = np.ones((10, 10, 10), dtype=np.float32)
+    darks = 2 * np.ones((3, 10, 10), dtype=np.uint16)
+    flats = 3 * np.ones((5, 10, 10), dtype=np.uint16)
+    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
+    aux_data = AuxiliaryData(angles=angles, darks=darks, flats=flats)
+    block = BaseBlock(data=data, aux_data=aux_data)
+
+    block.darks = block.darks.astype(np.float32)
+    block.flats = block.flats.astype(np.float32)
+
+    assert block.darks.dtype == np.float32
+    assert block.flats.dtype == np.float32
+    assert aux_data.get_darks().dtype == np.float32
+    assert aux_data.get_flats().dtype == np.float32
+
+
+def test_darks_and_flats_set_different_shape():
+    data = np.ones((10, 10, 10), dtype=np.float32)
+    darks = 2.0 * np.ones((3, 10, 10), dtype=np.float32)
+    flats = 3.0 * np.ones((5, 10, 10), dtype=np.float32)
+    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
+    aux_data = AuxiliaryData(angles=angles, darks=darks, flats=flats)
+    block = BaseBlock(data=data, aux_data=aux_data)
+
+    block.darks = np.mean(block.darks, axis=0, keepdims=True)
+    block.flats = np.mean(block.flats, axis=0, keepdims=True)
+
+    np.testing.assert_array_equal(
+        block.darks, 2.0 * np.ones((1, 10, 10), dtype=np.float32)
+    )
+    np.testing.assert_array_equal(
+        block.dark, 2.0 * np.ones((1, 10, 10), dtype=np.float32)
+    )
+    np.testing.assert_array_equal(
+        block.flats, 3.0 * np.ones((1, 10, 10), dtype=np.float32)
+    )
+    np.testing.assert_array_equal(
+        block.flat, 3.0 * np.ones((1, 10, 10), dtype=np.float32)
+    )
+    np.testing.assert_array_equal(
+        aux_data.get_darks(), 2.0 * np.ones((1, 10, 10), dtype=np.float32)
+    )
+    np.testing.assert_array_equal(
+        aux_data.get_flats(), 3.0 * np.ones((1, 10, 10), dtype=np.float32)
+    )
+
+
+def test_darks_and_flats_set_via_alias():
+    data = np.ones((10, 10, 10), dtype=np.float32)
+    darks = 2.0 * np.ones((3, 10, 10), dtype=np.float32)
+    flats = 3.0 * np.ones((5, 10, 10), dtype=np.float32)
+    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
+    aux_data = AuxiliaryData(angles=angles, darks=darks, flats=flats)
+    block = BaseBlock(data=data, aux_data=aux_data)
+
+    block.dark = np.mean(block.darks, axis=0, keepdims=True)
+    block.flat = np.mean(block.flats, axis=0, keepdims=True)
+
+    np.testing.assert_array_equal(
+        aux_data.get_darks(), 2.0 * np.ones((1, 10, 10), dtype=np.float32)
+    )
+    np.testing.assert_array_equal(
+        aux_data.get_flats(), 3.0 * np.ones((1, 10, 10), dtype=np.float32)
+    )
+
+
+def test_angles_set():
+    data = np.ones((10, 10, 10), dtype=np.float32)
+    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
+    aux_data = AuxiliaryData(angles=angles)
+    block = BaseBlock(data=data, aux_data=aux_data)
+
+    block.angles = np.ones(10, dtype=np.float32)
+
+    np.testing.assert_array_equal(aux_data.get_angles(), 1.0)
+
+
+def test_angles_set_via_alias():
+    data = np.ones((10, 10, 10), dtype=np.float32)
+    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
+    aux_data = AuxiliaryData(angles=angles)
+    block = BaseBlock(data=data, aux_data=aux_data)
+
+    block.angles_radians = np.ones(10, dtype=np.float32)
+
+    np.testing.assert_array_equal(aux_data.get_angles(), 1.0)
+
+
+def test_aux_can_drop_darks_flats():
+    # TODO: consider a separate test suite for AuxiliaryData
+    darks = 2.0 * np.ones((3, 10, 10), dtype=np.float32)
+    flats = 3.0 * np.ones((5, 10, 10), dtype=np.float32)
+    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
+    aux_data = AuxiliaryData(angles=angles, darks=darks, flats=flats)
+
+    assert aux_data.darks_dtype == np.float32
+    assert aux_data.flats_dtype == np.float32
+    assert aux_data.darks_shape == darks.shape
+    assert aux_data.flats_shape == flats.shape
+    assert aux_data.angles_dtype == np.float32
+    assert aux_data.angles_length == 10
+
+    aux_data.drop_darks_flats()
+
+    assert aux_data.get_darks() is None
+    assert aux_data.get_flats() is None
+    assert aux_data.darks_dtype is None
+    assert aux_data.flats_dtype is None
+    assert aux_data.darks_shape == (0, 0, 0)
+    assert aux_data.flats_shape == (0, 0, 0)
+    assert aux_data.angles_dtype == np.float32
+    assert aux_data.angles_length == 10
+
+
+# to_gpu, to_cpu (like DataSet) -> should not transfer flats/darks immediately
+
+
+# to_gpu when no GPU available should throw
+def test_to_gpu_fails_when_gpu_not_enabled(mocker):
+    mocker.patch("httomo.base_block.gpu_enabled", False)
+    data = np.ones((10, 10, 10), dtype=np.float32)
+    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
+    block = BaseBlock(data=data, aux_data=AuxiliaryData(angles=angles))
+    with pytest.raises(ValueError) as e:
+        block.to_gpu()
+
+    assert "no GPU available" in str(e)
+
+
+# to_gpu with enabled GPU transfers the data
+@pytest.mark.skipif(
+    not gpu_enabled or xp.cuda.runtime.getDeviceCount() == 0,
+    reason="skipped as cupy is not available",
+)
+def test_to_gpu_transfers_the_data():
+    data = np.ones((10, 10, 10), dtype=np.float32)
+    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
+    block = BaseBlock(data=data, aux_data=AuxiliaryData(angles=angles))
+
+    block.to_gpu()
+
+    assert block.is_gpu is True
+    assert block.is_cpu is False
+    assert block.data.device == xp.cuda.Device()
+
+
+@pytest.mark.skipif(
+    not gpu_enabled or xp.cuda.runtime.getDeviceCount() == 0,
+    reason="skipped as cupy is not available",
+)
+def test_to_gpu_twice_has_no_effect():
+    data = np.ones((10, 10, 10), dtype=np.float32)
+    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
+    block = BaseBlock(data=data, aux_data=AuxiliaryData(angles=angles))
+
+    block.to_gpu()
+    # this gives us the memory address (pointer) to the data on GPU
+    # since we want to make sure that no copy is taken on the second call
+    address = block.data.__cuda_array_interface__["data"]
+    block.to_gpu()
+
+    assert block.data.__cuda_array_interface__["data"] == address
+
+
+@pytest.mark.skipif(
+    not gpu_enabled or xp.cuda.runtime.getDeviceCount() == 0,
+    reason="skipped as cupy is not available",
+)
+def test_to_cpu_transfers_the_data_back():
+    data = np.ones((10, 10, 10), dtype=np.float32)
+    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
+    block = BaseBlock(data=data, aux_data=AuxiliaryData(angles=angles))
+    block.to_gpu()
+
+    block.to_cpu()
+
+    assert block.is_gpu is False
+    assert block.is_cpu is True
+    assert getattr(block.data, "device", None) is None
+
+
+def test_transfer_to_cpu_with_no_gpu(mocker: MockerFixture):
+    mocker.patch("httomo.base_block.gpu_enabled", False)
+    mocker.patch("httomo.base_block.xp", np)
+
+    data = np.ones((10, 10, 10), dtype=np.float32)
+    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
+    block = BaseBlock(data=data, aux_data=AuxiliaryData(angles=angles))
+
+    block.to_cpu()
+
+    assert block.is_gpu is False
+    assert block.is_cpu is True
+    assert getattr(block.data, "device", None) is None
+
+
+def test_to_cpu_twice_has_no_effect():
+    data = np.ones((10, 10, 10), dtype=np.float32)
+    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
+    block = BaseBlock(data=data, aux_data=AuxiliaryData(angles=angles))
+
+    # this gives us the memory address (pointer) to the data on CPU
+    # since we want to make sure that no copy is taken on the second call
+    address = block.data.__array_interface__["data"]
+    block.to_cpu()
+
+    assert block.data.__array_interface__["data"] == address
+
+
+# flats / darks should be returned on the same devices as where `data` is
+@pytest.mark.skipif(
+    not gpu_enabled or xp.cuda.runtime.getDeviceCount() == 0,
+    reason="skipped as cupy is not available",
+)
+def test_returns_flats_on_gpu_when_the_data_is_there():
+    data = np.ones((10, 10, 10), dtype=np.float32)
+    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
+    darks = 2.0 * np.ones((3, 10, 10), dtype=np.float32)
+    flats = 3.0 * np.ones((5, 10, 10), dtype=np.float32)
+    aux_data = AuxiliaryData(angles=angles, darks=darks, flats=flats)
+    block = BaseBlock(data=data, aux_data=aux_data)
+
+    block.to_gpu()
+
+    assert block.darks.device == xp.cuda.Device()
+    assert block.flats.device == xp.cuda.Device()
+
+
+def test_attributes_array(dummy_block: BaseBlock):
+    expected = set(
+        ["data", "flats", "darks", "angles", "angles_radians", "dark", "flat"]
+    )
+    actual = set(dir(dummy_block))
+    assert actual == expected
diff --git a/tests/test_cli_utils.py b/tests/test_cli_utils.py
new file mode 100644
index 000000000..a26824a08
--- /dev/null
+++ b/tests/test_cli_utils.py
@@ -0,0 +1,18 @@
+from pathlib import Path
+import pytest
+
+from httomo.cli_utils import is_sweep_pipeline
+
+
+@pytest.mark.parametrize(
+    "pipeline_file, expected_is_sweep_pipeline",
+    [
+        ("samples/pipeline_template_examples/testing/sweep_range.yaml", True),
+        ("samples/pipeline_template_examples/testing/example.yaml", False),
+        ("samples/pipeline_template_examples/testing/sweep_manual.py", True),
+        ("samples/python_templates/pipeline_cpu1.py", False),
+    ]
+)
+def test_is_sweep_pipeline(pipeline_file: Path, expected_is_sweep_pipeline: bool):
+    pipeline_file_path = Path(__file__).parent / pipeline_file
+    assert is_sweep_pipeline(pipeline_file_path) is expected_is_sweep_pipeline
diff --git a/tests/test_ui_layer.py b/tests/test_ui_layer.py
index a73262f82..9a4d338e4 100644
--- a/tests/test_ui_layer.py
+++ b/tests/test_ui_layer.py
@@ -1,4 +1,5 @@
 from pathlib import Path
+from typing import Any, Tuple
 from mpi4py import MPI
 from pytest_mock import MockerFixture
 from httomo.runner.output_ref import OutputRef
@@ -281,6 +282,33 @@ def test_pipeline_build_cpu3(
     assert ref.method.task_id == "statistics"
 
 
+@pytest.mark.parametrize(
+    "pipeline_file, expected_sweep_vals",
+    [
+        ("samples/pipeline_template_examples/testing/sweep_manual.yaml", (3, 5)),
+        (
+            "samples/pipeline_template_examples/testing/sweep_range.yaml",
+            tuple(range(3, 13)),
+        ),
+    ],
+    ids=["manual", "range"],
+)
+def test_build_pipeline_with_param_sweeps(
+    standard_data, pipeline_file: Path, expected_sweep_vals: Tuple[Any, ...]
+):
+    pipeline_file_path = Path(__file__).parent / pipeline_file
+    ui_layer = UiLayer(
+        tasks_file_path=pipeline_file_path,
+        in_data_file_path=standard_data,
+        comm=MPI.COMM_WORLD,
+    )
+    pipeline = ui_layer.build_pipeline()
+    sweep_method_wrapper = pipeline[1]
+    SWEEP_PARAM_NAME = "size"
+    assert SWEEP_PARAM_NAME in sweep_method_wrapper.config_params.keys()
+    assert sweep_method_wrapper.config_params[SWEEP_PARAM_NAME] == expected_sweep_vals
+
+
 @pytest.mark.parametrize(
     "refvalue",
     [
diff --git a/tests/test_yaml_checker.py b/tests/test_yaml_checker.py
index 917c5d271..b8af9f5b3 100644
--- a/tests/test_yaml_checker.py
+++ b/tests/test_yaml_checker.py
@@ -184,12 +184,13 @@ def test_check_side_out_matches_ref_arg(
         ("pipeline_cpu1.yaml", True),
         ("pipeline_cpu2.yaml", True),
         ("pipeline_gpu1.yaml", True),
-        #("parameter_sweeps/02_median_filter_kernel_sweep.yaml", True),
+        ("testing/sweep_manual.yaml", True),
     ],
     ids=[
         "cpu1_pipeline",
         "cpu2_pipeline",
         "gpu1_pipeline",
+        "sweep_manual",
     ],
 )
 def test_validate_yaml_config(