Repository: DiamondLightSource/httomo (language: python)

Issue:
working method to find CoR using phase correlation

Segfault in `Alltoallv()` operation in reslice
See the following Diamond Confluence page for details https://confluence.diamond.ac.uk/x/swC5D.

There is a workaround for it, but whether it's solving the root issue is unknown. The workaround is to use the custom MPI data type mentioned in the Confluence document, regardless of if the number of elements to send in the MPI operation actually exceeds the limit that can be sent or not.

In the future, it may be desired to further explore the reason for this behaviour, and to potentially propose a more satisfactory fix.


Discussion / hints:

Fix patch:
diff --git a/conda/environment.yml b/conda/environment.yml
index 6f506a5b8..547d8a525 100644
--- a/conda/environment.yml
+++ b/conda/environment.yml
@@ -4,20 +4,22 @@ channels:
   - conda-forge
   - httomo
   - anaconda
+  - rapidsai
 dependencies:
   - astra-toolbox::astra-toolbox
   - conda-forge::click>=8.1.3
-  - conda-forge::cupy
-  - conda-forge::h5py[build=*openmpi*]
+  - conda-forge::cupy<=12.3.0
+  - conda-forge::cudatoolkit
+  - conda-forge::h5py=*=*mpi_openmpi*
   - conda-forge::hdf5plugin
   - conda-forge::mpi4py
   - conda-forge::pyyaml
-  - conda-forge::numpy<1.24
+  - conda-forge::numpy<=1.25
   - conda-forge::nvtx
   - conda-forge::python
+  - conda-forge::plumbum
+  - conda-forge::typing_extensions
   - anaconda::ipython
   - conda-forge::tomopy
-  - httomo::larix
   - httomo::tomobar
-  - httomo::httomolib
-  - httomo::httomolibgpu
+  - rapidsai::cucim
diff --git a/httomo/cli.py b/httomo/cli.py
index f581f5d93..9caefd508 100644
--- a/httomo/cli.py
+++ b/httomo/cli.py
@@ -1,31 +1,40 @@
+from contextlib import AbstractContextManager, nullcontext
 from dataclasses import dataclass
 from datetime import datetime
 from pathlib import Path, PurePath
 from shutil import copy
-from typing import Optional, Union
+import sys
+import tempfile
+from typing import List, Optional, TextIO, Union
 
 import click
 from mpi4py import MPI
 
 import httomo.globals
 from httomo.logger import setup_logger
+from httomo.monitors import MONITORS_MAP, make_monitors
+from httomo.transform_layer import TransformLayer
 from httomo.yaml_checker import validate_yaml_config
 from httomo.runner.task_runner import TaskRunner
 from httomo.ui_layer import UiLayer
 
 from . import __version__
 
+
 @click.group
 @click.version_option(version=__version__, message="%(version)s")
 def main():
     """httomo: Software for High Throughput Tomography in parallel beam.
-     
-      Use `python -m httomo run --help` for more help on the runner. 
+
+    Use `python -m httomo run --help` for more help on the runner.
     """
     pass
 
+
 @main.command()
-@click.argument("in_data_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
+@click.argument(
+    "in_data_file", type=click.Path(exists=True, dir_okay=False, path_type=Path)
+)
 @click.argument(
     "yaml_config", type=click.Path(exists=True, dir_okay=False, path_type=Path)
 )
@@ -48,7 +57,33 @@ def main():
     "--reslice-dir",
     type=click.Path(exists=True, file_okay=False, writable=True, path_type=Path),
     default=None,
-    help="Enable reslicing through the disk by providing a folder path to store intermediate file)",
+    help="Directory for temporary files potentially needed for reslicing (defaults to output dir)",
+)
+@click.option(
+    "--max-cpu-slices",
+    type=click.INT,
+    default=64,
+    help="Maximum number of slices to use for a block for CPU-only sections (default: 64)"
+)
+@click.option(
+    "--max-memory",
+    type=click.STRING,
+    default="0",
+    help="Limit the amount of memory used by the pipeline to the given memory (supports strings like 3.2G or bytes)"
+)
+@click.option(
+    "--monitor",
+    type=click.STRING,
+    multiple=True,
+    default=[],
+    help=("Add monitor to the runner (can be given multiple times). " + 
+          f"Available monitors: {', '.join(MONITORS_MAP.keys())}")
+)
+@click.option(
+    "--monitor-output",
+    type=click.File('w'),
+    default=sys.stdout,
+    help="File to store the monitoring output. Defaults to '-', which denotes stdout"
 )
 def run(
     in_data_file: Path,
@@ -57,17 +92,28 @@ def run(
     gpu_id: int,
     save_all: bool,
     reslice_dir: Union[Path, None],
+    max_cpu_slices: int,
+    max_memory: str,
+    monitor: List[str],
+    monitor_output: TextIO,
 ):
     """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
+
     # First we need to validate yaml configuration file if there are any errors
     # TODO: with new yaml syntax check yaml is not fully working.
-    # Need to re-enable that: 
+    # Need to re-enable that:
     # _check_yaml(yaml_config, in_data_file)
 
+    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
+
     # Define httomo.globals.run_out_dir in all MPI processes
     httomo.globals.run_out_dir = out_dir.joinpath(
-            f"{datetime.now().strftime('%d-%m-%Y_%H_%M_%S')}_output"
+        f"{datetime.now().strftime('%d-%m-%Y_%H_%M_%S')}_output"
     )
     comm = MPI.COMM_WORLD
     if comm.rank == 0:
@@ -99,11 +145,43 @@ def run(
     # instantiate UiLayer class for pipeline build
     init_UiLayer = UiLayer(yaml_config, in_data_file, comm=comm)
     pipeline = init_UiLayer.build_pipeline()
-    
-    # Run the pipeline using Taskrunner
-    runner = TaskRunner(pipeline, save_all, reslice_dir)
-    return runner.execute()
+
+    # perform transformations on pipeline
+    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)
+
 
 def _check_yaml(yaml_config: Path, in_data: Path):
-    """Check a YAML pipeline file for errors."""    
-    return validate_yaml_config(yaml_config, in_data)
\ No newline at end of file
+    """Check a YAML pipeline file for errors."""
+    return validate_yaml_config(yaml_config, in_data)
+
+
+def transform_limit_str_to_bytes(limit_str: str):
+    try:
+        limit_upper = limit_str.upper()
+        if limit_upper.endswith("K"):
+            return int(float(limit_str[:-1]) * 1024)
+        elif limit_upper.endswith("M"):
+            return int(float(limit_str[:-1]) * 1024**2)
+        elif limit_upper.endswith("G"):
+            return int(float(limit_str[:-1]) * 1024**3)
+        else: 
+            return int(limit_str) 
+    except ValueError:
+        raise ValueError(f"invalid memory limit string {limit_str}")
diff --git a/httomo/darks_flats.py b/httomo/darks_flats.py
new file mode 100644
index 000000000..7ede91e76
--- /dev/null
+++ b/httomo/darks_flats.py
@@ -0,0 +1,51 @@
+from pathlib import Path
+from typing import NamedTuple, Optional, Tuple
+
+import h5py
+import numpy as np
+
+from httomo.preview import PreviewConfig
+
+
+class DarksFlatsFileConfig(NamedTuple):
+    file: Path
+    data_path: str
+    image_key_path: Optional[str]
+
+
+def get_darks_flats(
+    darks_config: DarksFlatsFileConfig,
+    flats_config: DarksFlatsFileConfig,
+    preview_config: PreviewConfig,
+) -> Tuple[np.ndarray, np.ndarray]:
+    def get_together() -> Tuple[np.ndarray, np.ndarray]:
+        with h5py.File(darks_config.file, "r") as f:
+            darks_indices = np.where(f[darks_config.image_key_path][:] == 2)[0]
+            flats_indices = np.where(f[flats_config.image_key_path][:] == 1)[0]
+            dataset: h5py.Dataset = f[darks_config.data_path]
+            darks = dataset[
+                darks_indices,
+                preview_config.detector_y.start : preview_config.detector_y.stop,
+                preview_config.detector_x.start : preview_config.detector_x.stop,
+            ]
+            flats = dataset[
+                flats_indices,
+                preview_config.detector_y.start : preview_config.detector_y.stop,
+                preview_config.detector_x.start : preview_config.detector_x.stop,
+            ]
+        return darks, flats
+
+    def get_separate(config: DarksFlatsFileConfig):
+        with h5py.File(config.file, "r") as f:
+            return f[config.data_path][
+                :,
+                preview_config.detector_y.start : preview_config.detector_y.stop,
+                preview_config.detector_x.start : preview_config.detector_x.stop,
+            ]
+
+    if darks_config.file != flats_config.file:
+        darks = get_separate(darks_config)
+        flats = get_separate(flats_config)
+        return darks, flats
+
+    return get_together()
diff --git a/httomo/data/dataset_store.py b/httomo/data/dataset_store.py
new file mode 100644
index 000000000..574bcbd1c
--- /dev/null
+++ b/httomo/data/dataset_store.py
@@ -0,0 +1,449 @@
+from os import PathLike
+from pathlib import Path
+import time
+import h5py
+from typing import Literal, Optional, Tuple, Union
+from httomo.data.hdf._utils.reslice import reslice
+from httomo.runner.auxiliary_data import AuxiliaryData
+from httomo.runner.dataset import DataSetBlock
+from httomo.runner.dataset_store_interfaces import (
+    DataSetSource,
+    ReadableDataSetSink,
+)
+from mpi4py import MPI
+import numpy as np
+from numpy.typing import DTypeLike
+import weakref
+
+from httomo.utils import Colour, log_once, make_3d_shape_from_shape
+
+"""
+This is from the final handover call:
+
+# Cases
+
+- *We always process the data in blocks*
+
+## 1 Process, all fits memory
+
+- chunk_shape = global_shape   
+- chunk_index = (0, 0, 0)
+- the whole chunk is a numpy array, in a `DataSet` in-memory
+- `write_block` -> writes a `DataSetBlock` into the chunk `DataSet`
+- `read_block` -> get a block, which might be the full size of the `DataSet` for the chunk, 
+  it's an in-memory slice of the chunk `DataSet`
+
+### Reslice
+
+- does nothing mostly
+- updates `slicing_dim` somewhere, so that `read_block` knows how to slice the block
+
+## 2 Processes, all fits in memory (or more)
+
+(assume slicing dim is 0)
+
+- chunk_shape < global_shape, e.g. `(5, 20, 30) < (10, 20, 30)`
+- `chunk_index`: in rank 0: `(0, 0, 0)`, in rank 1: `(5, 0, 0)`
+- the whole chunk is in numpy array, in a `DataSet`, in-memory
+- BUT: each process as a part of the global data in-memory
+- `write_block` -> writes a `DataSetBlock` into the chunk `DataSet`
+- `read_block` -> get a block, which might be the full size of the `DataSet` for the chunk
+
+### Reslice
+
+- call the MPI memory-based reslice function we always had (using `MPI.allgather`)
+- we have a new chunk in each process, wich chunk_shape=`(10, 10, 30)`
+- we have `chunk_index`: in rank 0: `(0, 0, 0)`, in rank 1: `(0, 10, 0)`
+- updates `slicing_dim` somewhere, so that `read_block` knows how to slice the block
+
+
+## 2 Processes, doesn't memory (or more)
+
+(assume slicing dim is 0)
+
+- chunk_shape < global_shape, e.g. `(5, 20, 30) < (10, 20, 30)`
+- `chunk_index`: in rank 0: `(0, 0, 0)`, in rank 1: `(5, 0, 0)`
+- *the global data is fully is a single h5py file*
+  - it is the whole global cube!
+  - each process only needs to access a chunk, block-wise, out of that file
+  - has the same interface as numpy array (`shape`, indexing, ...)
+  - BUT: as soon as we index, we read the file into an in-memory numpy array
+  - --> we cannot create a "view" of a subset of the file, referencing the disk
+  - so, each process needs to keep track of the start of its chunk within the file, 
+    and when read_block is called with start index 0, we add the chunk_index to that
+- `write_block` -> writes a `DataSetBlock` into the file, with the correct offset (`chunk_index + block_index`)
+- `read_block` -> get a block from the file, with offset `chunk_index + block_index`
+- --> `FullFileDataSet` class takes care of that
+
+### Reslice
+
+- we have full globally shared file, all the data is there on disk already
+- does nothing mostly
+- updates `slicing_dim` somewhere, so that `read_block` knows how to slice the block
+- update `chunk_index`: in rank 0: `(0, 0, 0)`, in rank 1: `(0, 10, 0)`
+
+
+"""
+
+# Notes:
+# - refactoring the nested if into separate function is badly needed
+
+
+class DataSetStoreWriter(ReadableDataSetSink):
+    """A DataSetSink that can be used to store block-wise data in the current chunk (for the current process).
+
+    It uses memory by default - but if there's a memory allocation error, a temporary h5 file is used
+    to back the dataset's memory.
+
+    The `make_reader` method can be used to create a DataSetStoreReader from this writer.
+    It is intended to be used after the writer has finished, to read the data blockwise again.
+    It will use the same underlying data store (h5 file or memory)"""
+
+    def __init__(
+        self,
+        slicing_dim: Literal[0, 1, 2],
+        comm: MPI.Comm,
+        temppath: PathLike,
+        memory_limit_bytes: int = 0,
+    ):
+        self._slicing_dim = slicing_dim
+        self._comm = comm
+
+        self._temppath = temppath
+        self._readonly = False
+        self._h5file: Optional[h5py.File] = None
+        self._h5filename: Optional[Path] = None
+        self._memory_limit_bytes: int = memory_limit_bytes
+
+        self._data: Optional[Union[np.ndarray, h5py.Dataset]] = None
+
+        self._global_shape: Optional[Tuple[int, int, int]] = None
+        self._chunk_shape: Optional[Tuple[int, int, int]] = None
+        self._global_index: Optional[Tuple[int, int, int]] = None
+
+        # make sure finalize is called when this object is garbage-collected
+        weakref.finalize(self, weakref.WeakMethod(self.finalize))
+
+    @property
+    def is_file_based(self) -> bool:
+        return self._h5filename is not None
+
+    @property
+    def filename(self) -> Optional[Path]:
+        return self._h5filename
+
+    @property
+    def comm(self) -> MPI.Comm:
+        return self._comm
+
+    # ??? do we need these properties?
+    @property
+    def global_shape(self) -> Tuple[int, int, int]:
+        assert self._global_shape is not None
+        return self._global_shape
+
+    @property
+    def chunk_shape(self) -> Tuple[int, int, int]:
+        assert self._chunk_shape is not None
+        return self._chunk_shape
+
+    @property
+    def global_index(self) -> Tuple[int, int, int]:
+        assert self._global_index is not None
+        return self._global_index
+
+    @property
+    def slicing_dim(self) -> Literal[0, 1, 2]:
+        return self._slicing_dim
+
+    @property
+    def aux_data(self) -> AuxiliaryData:
+        return self._aux_data
+
+    def write_block(self, block: DataSetBlock):
+        if self._readonly:
+            raise ValueError("Cannot write after creating a reader")
+        block.to_cpu()
+        start = max(block.chunk_index)
+        if self._data is None:
+            # if non-slice dims in block are different, update the shapes here
+            self._global_shape = block.global_shape
+            self._chunk_shape = block.chunk_shape
+            self._global_index = (
+                block.global_index[0] - block.chunk_index[0],
+                block.global_index[1] - block.chunk_index[1],
+                block.global_index[2] - block.chunk_index[2],
+            )
+            self._aux_data = block.aux_data
+            self._create_new_data(block)
+        else:
+            assert self._global_shape is not None
+            assert self._chunk_shape is not None
+            assert self._global_index is not None
+            if any(self._global_shape[i] != block.global_shape[i] for i in range(3)):
+                raise ValueError(
+                    "Attempt to write a block with inconsistent shape to existing data"
+                )
+
+            if any(self._chunk_shape[i] != block.chunk_shape[i] for i in range(3)):
+                raise ValueError(
+                    "Attempt to write a block with inconsistent shape to existing data"
+                )
+
+            if any(
+                self._global_index[i] != block.global_index[i] - block.chunk_index[i]
+                for i in range(3)
+            ):
+                raise ValueError(
+                    "Attempt to write a block with inconsistent shape to existing data"
+                )
+
+        # insert the slice here
+        assert self._data is not None  # after the above methods, this must be set
+        start_idx = [0, 0, 0]
+        start_idx[self._slicing_dim] = start
+        if self.is_file_based:
+            start_idx[self._slicing_dim] += self._global_index[self._slicing_dim]
+        self._data[
+            start_idx[0] : start_idx[0] + block.shape[0],
+            start_idx[1] : start_idx[1] + block.shape[1],
+            start_idx[2] : start_idx[2] + block.shape[2],
+        ] = block.data
+
+    def _get_global_h5_filename(self) -> PathLike:
+        """Creates a temporary h5 file to back the storage (using nanoseconds timestamp
+        for uniqueness).
+        """
+        filename = str(Path(self._temppath) / f"httom_tmpstore_{time.time_ns()}.hdf5")
+        filename = self.comm.bcast(filename, root=0)
+
+        self._h5filename = Path(filename)
+        return self._h5filename
+
+    def _create_new_data(self, block: DataSetBlock):
+        # reduce memory errors across all processes - if any has a memory problem,
+        # all should use a file
+        sendBuffer = np.zeros(1, dtype=bool)
+        recvBuffer = np.zeros(1, dtype=bool)
+        try:
+            self._data = self._create_numpy_data(self.chunk_shape, block.data.dtype)
+        except MemoryError:
+            sendBuffer[0] = True
+
+        # do a logical or of all the memory errors across the processes
+        self.comm.Allreduce([sendBuffer, MPI.BOOL], [recvBuffer, MPI.BOOL], MPI.LOR)
+
+        if bool(recvBuffer[0]) is True:
+            log_once(
+                "Chunk does not fit in memory - using a file-based store",
+                self.comm,
+                Colour.YELLOW,
+                level=2,
+            )
+            # we create a full file dataset, i.e. file-based,
+            # with the full global shape in it
+            self._data = self._create_h5_data(
+                self.global_shape,
+                block.data.dtype,
+                self._get_global_h5_filename(),
+                self.comm,
+            )
+
+    def _create_numpy_data(
+        self, chunk_shape: Tuple[int, int, int], dtype: DTypeLike
+    ) -> np.ndarray:
+        """Convenience method to enable mocking easily"""
+        if (
+            self._memory_limit_bytes > 0
+            and np.prod(chunk_shape) * np.dtype(dtype).itemsize
+            >= self._memory_limit_bytes
+        ):
+            raise MemoryError("Memory limit reached")
+
+        return np.empty(chunk_shape, dtype)
+
+    def _create_h5_data(
+        self,
+        global_shape: Tuple[int, int, int],
+        dtype: DTypeLike,
+        file: PathLike,
+        comm: MPI.Comm,
+    ) -> h5py.Dataset:
+        """Creates a h5 data file based on the file-like object given.
+        The returned data object behaves like a numpy array, so can be used freely within
+        a DataSet."""
+
+        self._h5file = h5py.File(file, "w", driver="mpio", comm=comm)
+        # set how data should be chunked when saving
+        # chunks = list(global_shape)
+        # chunks[slicing_dim] = 1
+        # TODO: best first measure performance impact
+        # probably good chunk is (4, 4, <detector_x>)
+        h5data = self._h5file.create_dataset("data", global_shape, dtype)
+
+        return h5data
+
+    def make_reader(
+        self, new_slicing_dim: Optional[Literal[0, 1, 2]] = None
+    ) -> DataSetSource:
+        """Create a reader from this writer, reading from the same store"""
+        if self._data is None:
+            raise ValueError("Cannot make reader when no data has been written yet")
+        self._readonly = True
+        if self._h5file is not None:
+            self._h5file.close()
+            self._h5file = None
+        reader = DataSetStoreReader(self, new_slicing_dim)
+        # make sure finalize is called when reader object is garbage-collected
+        weakref.finalize(reader, weakref.WeakMethod(reader.finalize))
+        return reader
+
+    def finalize(self):
+        self._data = None
+        if self._h5file is not None:
+            self._h5file.close()
+            self._h5file = None
+
+
+class DataSetStoreReader(DataSetSource):
+    """Class to read from a store that has previously been written by DataSetStoreWriter,
+    in a block-wise fashion.
+    """
+
+    def __init__(
+        self, source: DataSetStoreWriter, slicing_dim: Optional[Literal[0, 1, 2]] = None
+    ):
+        self._comm = source.comm
+        self._global_shape = source.global_shape
+        self._global_index = source.global_index
+        self._chunk_shape = source.chunk_shape
+        self._aux_data = source.aux_data
+        if source._data is None:
+            raise ValueError(
+                "Cannot create DataSetStoreReader when no data has been written"
+            )
+
+        self._h5file: Optional[h5py.File] = None
+        self._h5filename: Optional[Path] = None
+        source_data = source._data
+        if source.is_file_based:
+            self._h5filename = source.filename
+            self._h5file = h5py.File(source.filename, "r")
+            source_data = self._h5file["data"]
+
+        if slicing_dim is None or slicing_dim == source.slicing_dim:
+            self._slicing_dim = source.slicing_dim
+            self._data = source_data
+        else:
+            self._data = self._reslice(source.slicing_dim, slicing_dim, source_data)
+            self._slicing_dim = slicing_dim
+
+        source.finalize()
+
+    @property
+    def aux_data(self) -> AuxiliaryData:
+        return self._aux_data
+
+    @property
+    def dtype(self) -> np.dtype:
+        return self._data.dtype
+
+    @property
+    def is_file_based(self) -> bool:
+        return self._h5filename is not None
+
+    @property
+    def filename(self) -> Optional[Path]:
+        return self._h5filename
+
+    @property
+    def global_shape(self) -> Tuple[int, int, int]:
+        return self._global_shape
+
+    @property
+    def chunk_shape(self) -> Tuple[int, int, int]:
+        return self._chunk_shape
+
+    @property
+    def global_index(self) -> Tuple[int, int, int]:
+        return self._global_index
+
+    @property
+    def slicing_dim(self) -> Literal[0, 1, 2]:
+        return self._slicing_dim
+
+    def _reslice(
+        self,
+        old_slicing_dim: Literal[0, 1, 2],
+        new_slicing_dim: Literal[0, 1, 2],
+        data: Union[np.ndarray, h5py.Dataset],
+    ) -> Union[np.ndarray, h5py.Dataset]:
+        assert old_slicing_dim != new_slicing_dim
+        if self._comm.size == 1:
+            assert data.shape == self._global_shape
+            assert self._comm.size == 1
+            return data
+        else:
+            assert self._comm.size > 1
+            if isinstance(data, np.ndarray):  # in-memory chunks
+                # we only see a chunk, so we need to do MPI-based reslicing
+                array, newdim, startidx = reslice(
+                    data, old_slicing_dim + 1, new_slicing_dim + 1, self._comm
+                )
+                self._chunk_shape = array.shape  #  type: ignore
+                assert newdim == new_slicing_dim + 1
+                idx = [0, 0, 0]
+                idx[new_slicing_dim] = startidx
+                self._global_index = (idx[0], idx[1], idx[2])
+                return array
+            else:
+                # we have a full, file-based dataset - all we have to do
+                # is calculate the new chunk shape and start index
+                rank = self._comm.rank
+                nproc = self._comm.size
+                length = self._global_shape[new_slicing_dim]
+                startidx = round((length / nproc) * rank)
+                stopidx = round((length / nproc) * (rank + 1))
+                chunk_shape = list(self._global_shape)
+                chunk_shape[new_slicing_dim] = stopidx - startidx
+                self._chunk_shape = make_3d_shape_from_shape(chunk_shape)
+                idx = [0, 0, 0]
+                idx[new_slicing_dim] = startidx
+                self._global_index = (idx[0], idx[1], idx[2])
+                return data
+
+    def read_block(self, start: int, length: int) -> DataSetBlock:
+        start_idx = [0, 0, 0]
+        start_idx[self._slicing_dim] = start
+        if self.is_file_based:
+            start_idx[self._slicing_dim] += self._global_index[self._slicing_dim]
+        shape = list(self._global_shape)
+        shape[self._slicing_dim] = length
+
+        block_data = self._data[
+            start_idx[0] : start_idx[0] + shape[0],
+            start_idx[1] : start_idx[1] + shape[1],
+            start_idx[2] : start_idx[2] + shape[2],
+        ]
+
+        block = DataSetBlock(
+            data=block_data,
+            aux_data=self._aux_data,
+            slicing_dim=self._slicing_dim,
+            block_start=start,
+            chunk_start=self._global_index[self._slicing_dim],
+            global_shape=self._global_shape,
+            chunk_shape=self._chunk_shape,
+        )
+
+        return block
+
+    def finalize(self):
+        self._data = None
+        if self._h5file is not None:
+            self._h5file.close()
+            self._h5file = None
+        # also delete the file
+        if self._h5filename is not None and self._comm.rank == 0:
+            self._h5filename.unlink()
+            self._h5filename = None
diff --git a/httomo/data/hdf/_utils/chunk.py b/httomo/data/hdf/_utils/chunk.py
index b153d9dc5..6fb87b695 100644
--- a/httomo/data/hdf/_utils/chunk.py
+++ b/httomo/data/hdf/_utils/chunk.py
@@ -1,5 +1,5 @@
-from typing import Tuple
-from pathlib import Path
+from os import PathLike
+from typing import Optional, Tuple
 
 import h5py as h5
 from mpi4py import MPI
@@ -8,12 +8,12 @@
 
 
 def save_dataset(
-    out_folder: Path,
+    out_folder: PathLike,
     file_name: str,
     data: ndarray,
-    angles: ndarray,
-    detector_x: int,
-    detector_y: int,
+    angles: Optional[ndarray] = None,
+    detector_x: int = -1,
+    detector_y: int = -1,
     slice_dim: int = 1,
     chunks: Tuple = (150, 150, 10),
     path: str = "/data",
@@ -24,7 +24,7 @@ def save_dataset(
 
     Parameters
     ----------
-    out_folder : str
+    out_folder : PathLike
         Path to output folder.
     file_name : str
         Name of file to save dataset in.
@@ -63,7 +63,8 @@ def save_dataset(
     ) as file:
         dataset = file.create_dataset(path, shape, dtype, chunks=chunks)
         save_data_parallel(dataset, data, slice_dim)
-        file.create_dataset("/angles", data=angles)
+        if angles is not None:
+            file.create_dataset("/angles", data=angles)
         file.create_dataset(file_name, data=[0, 0])
         g1 = file.create_group("data_dims")
         g1.create_dataset("detector_x_y", data=[detector_x, detector_y])
diff --git a/httomo/data/hdf/_utils/load.py b/httomo/data/hdf/_utils/load.py
index 5bf7bf77f..4ab24615c 100755
--- a/httomo/data/hdf/_utils/load.py
+++ b/httomo/data/hdf/_utils/load.py
@@ -16,7 +16,7 @@ def load_data(
     preview: str = ":,:,:",
     pad: Tuple = (0, 0),
     comm: MPI.Comm = MPI.COMM_WORLD,
-) -> ndarray:
+) -> Tuple[ndarray, int]:
     """Load data in parallel, slicing it through a certain dimension.
 
     Parameters
@@ -37,21 +37,20 @@ def load_data(
 
     Returns
     -------
-    ndarray
-        The numpy array that has been loaded.
+    Tuple[ndarray, int]
+        The numpy array that has been loaded and the start index in the global shape.
     """
     log_once(f"Loading data: {file}", colour=Colour.LYELLOW, comm=comm, level=1)
     log_once(f"Path to data: {path}", colour=Colour.LYELLOW, comm=comm, level=1)
     log_once(f"Preview: ({preview})", colour=Colour.LYELLOW, comm=comm, level=1)
     if dim == 1:
-        data = read_through_dim1(file, path, preview=preview, pad=pad, comm=comm)
+        return read_through_dim1(file, path, preview=preview, pad=pad, comm=comm)
     elif dim == 2:
-        data = read_through_dim2(file, path, preview=preview, pad=pad, comm=comm)
+        return read_through_dim2(file, path, preview=preview, pad=pad, comm=comm)
     elif dim == 3:
-        data = read_through_dim3(file, path, preview=preview, pad=pad, comm=comm)
+        return read_through_dim3(file, path, preview=preview, pad=pad, comm=comm)
     else:
         raise Exception("Invalid dimension. Choose 1, 2 or 3.")
-    return data
 
 
 def read_through_dim3(
@@ -60,7 +59,7 @@ def read_through_dim3(
     preview: str = ":,:,:",
     pad: Tuple = (0, 0),
     comm: MPI.Comm = MPI.COMM_WORLD,
-) -> ndarray:
+) -> Tuple[ndarray, int]:
     """Read a dataset in parallel, with each MPI process loading a block.
 
     Parameters
@@ -78,7 +77,7 @@ def read_through_dim3(
 
     Returns
     -------
-    ndarray
+    Tuple[ndarray, int]
         ADD DESC
     """
     rank = comm.rank
@@ -104,8 +103,10 @@ def read_through_dim3(
             offset = start  # Offset where the dataset will start being read.
         # Bounds of the data this process will load. Length is split between number of
         # processes.
-        i0 = round((length / nproc) * rank) + offset - pad[0]
-        i1 = round((length / nproc) * (rank + 1)) + offset + pad[1]
+        chunk_idx = round((length / nproc) * rank)
+        next_chunk_idx = round((length / nproc) * (rank + 1))
+        i0 = chunk_idx + offset - pad[0]
+        i1 = next_chunk_idx + offset + pad[1]
         # Checking that i0 and i1 are still within the bounds of the dataset after
         # padding.
         if i0 < 0:
@@ -113,7 +114,7 @@ def read_through_dim3(
         if i1 > dataset.shape[2]:
             i1 = dataset.shape[2]
         data = dataset[:, :, i0:i1:step]
-        return data
+        return data, chunk_idx
 
 
 def read_through_dim2(
@@ -122,7 +123,7 @@ def read_through_dim2(
     preview: str = ":,:,:",
     pad: Tuple = (0, 0),
     comm: MPI.Comm = MPI.COMM_WORLD,
-) -> ndarray:
+) -> Tuple[ndarray, int]:
     """Read a dataset in parallel, with each MPI process loading a block.
 
     Parameters
@@ -140,8 +141,8 @@ def read_through_dim2(
 
     Returns
     -------
-    ndarray
-        ADD DESC
+    Tuple[ndarray, int]
+        The data loaded and its starting index in the global shape
     """
     rank = comm.rank
     nproc = comm.size
@@ -166,8 +167,10 @@ def read_through_dim2(
             offset = start  # Offset where the dataset will start being read.
         # Bounds of the data this process will load. Length is split between number of
         # processes.
-        i0 = round((length / nproc) * rank) + offset - pad[0]
-        i1 = round((length / nproc) * (rank + 1)) + offset + pad[1]
+        chunk_idx = round((length / nproc) * rank)
+        next_chunk_idx = round((length / nproc) * (rank + 1))
+        i0 = chunk_idx + offset - pad[0]
+        i1 = next_chunk_idx + offset + pad[1]
         # Checking that i0 and i1 are still within the bounds of the dataset after
         # padding.
         if i0 < 0:
@@ -175,7 +178,7 @@ def read_through_dim2(
         if i1 > dataset.shape[1]:
             i1 = dataset.shape[1]
         data = dataset[slice_list[0], i0:i1:step, :]
-        return data
+        return data, chunk_idx
 
 
 def read_through_dim1(
@@ -184,7 +187,7 @@ def read_through_dim1(
     preview: str = ":,:,:",
     pad: Tuple = (0, 0),
     comm: MPI.Comm = MPI.COMM_WORLD,
-) -> ndarray:
+) -> Tuple[ndarray, int]:
     """Read a dataset in parallel, with each MPI process loading a block.
 
     Parameters
@@ -202,8 +205,8 @@ def read_through_dim1(
 
     Returns
     -------
-    ndarray
-        ADD DESC
+    Tuple[ndarray, int]
+        The data loaded and its starting index in the global shape
     """
     rank = comm.rank
     nproc = comm.size
@@ -228,8 +231,10 @@ def read_through_dim1(
             offset = start  # Offset where the dataset will start being read.
         # Bounds of the data this process will load. Length is split between number of
         # processes.
-        i0 = round((length / nproc) * rank) + offset - pad[0]
-        i1 = round((length / nproc) * (rank + 1)) + offset + pad[1]
+        chunk_idx = round((length / nproc) * rank)
+        next_chunk_idx = round((length / nproc) * (rank + 1))
+        i0 = chunk_idx + offset - pad[0]
+        i1 = next_chunk_idx + offset + pad[1]
         # Checking that i0 and i1 are still within the bounds of the dataset after
         # padding.
         if i0 < 0:
@@ -237,7 +242,7 @@ def read_through_dim1(
         if i1 > dataset.shape[0]:
             i1 = dataset.shape[0]
         data = dataset[i0:i1:step, slice_list[1], slice_list[2]]
-        return data
+        return data, chunk_idx
 
 
 def get_pad_values(
diff --git a/httomo/data/hdf/_utils/reslice.py b/httomo/data/hdf/_utils/reslice.py
index 7f6e9936e..5bee2e6fb 100644
--- a/httomo/data/hdf/_utils/reslice.py
+++ b/httomo/data/hdf/_utils/reslice.py
@@ -1,3 +1,4 @@
+from os import PathLike
 from pathlib import Path
 from typing import Optional, Tuple
 from mpi4py import MPI
@@ -15,7 +16,7 @@ def reslice(
     current_slice_dim: int,
     next_slice_dim: int,
     comm: Comm,
-) -> Tuple[numpy.ndarray, int]:
+) -> Tuple[numpy.ndarray, int, int]:
     """Reslice data by using in-memory MPI directives.
 
     Parameters
@@ -31,9 +32,9 @@ def reslice(
         The MPI communicator to be used.
 
     Returns:
-    tuple[numpy.ndarray, int]:
-        A tuple containing the resliced data and the dimension along which it is
-        now sliced.
+    tuple[numpy.ndarray, int, int]:
+        A tuple containing the resliced data, the dimension along which it is
+        now sliced, and the starting index in slicing dimension for the current process.
     """
     log_once(
         f"<-------Reslicing/rechunking the data-------->",
@@ -50,7 +51,7 @@ def reslice(
             colour=Colour.BLUE,
             level=1,
         )
-        return data, next_slice_dim
+        return data, next_slice_dim, 0
 
     # Get shape of full/unsplit data, in order to set the chunk shape based on
     # the dims of the full data rather than of the split data
@@ -68,7 +69,8 @@ def reslice(
         mpiutil.alltoall(to_scatter), axis=current_slice_dim - 1
     )
 
-    return new_data, next_slice_dim
+    start_idx = 0 if comm.rank == 0 else split_indices[comm.rank-1]
+    return new_data, next_slice_dim, start_idx
 
 
 def reslice_filebased(
@@ -79,8 +81,8 @@ def reslice_filebased(
     detector_x: int,
     detector_y: int,
     comm: Comm,
-    reslice_dir: Path,
-) -> Tuple[numpy.ndarray, int]:
+    reslice_dir: PathLike,
+) -> Tuple[numpy.ndarray, int, int]:
     """Reslice data by writing to hdf5 store with data chunked along a different
     dimension, and reading back along the new chunking dimension.
     Parameters
@@ -103,9 +105,9 @@ def reslice_filebased(
     comm : Comm
         The MPI communicator to be used.
     Returns:
-    tuple[numpy.ndarray, int]:
+    tuple[numpy.ndarray, int, int]:
         A tuple containing the resliced data and the dimension along which it is
-        now sliced.
+        now sliced and the start index in that dimension.
     """
     # Get shape of full/unsplit data, in order to set the chunk shape based on
     # the dims of the full data rather than of the split data
@@ -113,16 +115,9 @@ def reslice_filebased(
 
     # Calculate the chunk size for the resliced data
     slices_no_in_chunks = 1
-    if next_slice_dim == 1:
-        # Chunk along projection (rotation angle) dimension
-        chunks_data = (slices_no_in_chunks, data_shape[1], data_shape[2])
-    elif next_slice_dim == 2:
-        # Chunk along sinogram (detector y) dimension
-        chunks_data = (data_shape[0], slices_no_in_chunks, data_shape[2])
-    else:
-        # Chunk along detector x dimension
-        chunks_data = (data_shape[0], data_shape[1], slices_no_in_chunks)
-
+    chunks_data = list(data_shape)
+    chunks_data[next_slice_dim - 1] = slices_no_in_chunks
+    
     log_once(
         f"<-------Reslicing/rechunking the data-------->",
         comm,
@@ -136,20 +131,17 @@ def reslice_filebased(
         reslice_dir,
         "intermediate.h5",
         data,
-        angles,
-        detector_x,
-        detector_y,
-        current_slice_dim,
-        chunks_data,
+        slice_dim=current_slice_dim,
+        chunks=tuple(chunks_data),
         reslice=True,
         comm=comm,
     )
     # Read data back along the new slicing dimension
-    data = load.load_data(
+    data, start_idx = load.load_data(
         f"{reslice_dir}/intermediate.h5", next_slice_dim, "/data", comm=comm
     )
 
-    return data, next_slice_dim
+    return data, next_slice_dim, start_idx
 
 def single_sino_reslice(
     data: numpy.ndarray,
@@ -192,4 +184,7 @@ def single_sino_reslice(
     )
 
     if mpiutil.rank == 0:
+        assert recvbuf is not None
         return recvbuf.reshape((data_shape[0], data_shape[2]))
+    else:
+        return None
diff --git a/httomo/data/hdf/loaders.py b/httomo/data/hdf/loaders.py
index 0168161c5..337c79fe7 100644
--- a/httomo/data/hdf/loaders.py
+++ b/httomo/data/hdf/loaders.py
@@ -139,7 +139,7 @@ def standard_tomo(
         comm=comm,
     )
     log_rank(f"Pad values are {pad_values}.", comm)
-    data = load.load_data(
+    data, _ = load.load_data(
         str(in_file), dim, data_path, preview=preview_str, pad=pad_values, comm=comm
     )
 
diff --git a/httomo/data/mpiutil.py b/httomo/data/mpiutil.py
index 0981faab5..ec97fb435 100644
--- a/httomo/data/mpiutil.py
+++ b/httomo/data/mpiutil.py
@@ -5,8 +5,8 @@
 try:
     from mpi4py import MPI
 
-    comm = MPI.COMM_WORLD.__sizeof__()
-    enabled = comm > 1
+    comsize = MPI.COMM_WORLD.__sizeof__()
+    enabled = comsize > 1
 except ImportError:
     enabled = False
 
@@ -92,41 +92,39 @@ def alltoall(arrays: List[np.ndarray]) -> List[np.ndarray]:
     sizes_rec = [np.prod(sh) for sh in shapes_rec]
     fulloutput = np.empty((np.sum(sizes_rec),), dtype=arrays[0].dtype)
 
-    if any(s >= _mpi_max_elements for s in sizes_send) or any(
-        s >= _mpi_max_elements for s in sizes_rec
-    ):
-        # find the dim which is equal in all arrays to send/receive
-        dim0s = [s[0] for s in shapes_send] + [s[0] for s in shapes_rec]
-        dim1s = [s[1] for s in shapes_send] + [s[1] for s in shapes_rec]
-        dim2s = [s[2] for s in shapes_send] + [s[2] for s in shapes_rec]
-        dim0equal = all(s == dim0s[0] for s in dim0s)
-        dim1equal = all(s == dim1s[1] for s in dim1s)
-        dim2equal = all(s == dim2s[2] for s in dim2s)
-        assert (
-            dim0equal or dim1equal or dim2equal
-        ), "At least one dimension of the input arrays must be of same size"
-
-        # create a new contiguous MPI datatype by repeating the input type by this common length
-        factor = (
-            arrays[0].shape[0]
-            if dim0equal
-            else arrays[0].shape[1]
-            if dim1equal
-            else arrays[0].shape[2]
-        )
-        dtype1 = dtype.Create_contiguous(factor).Commit()
-        # sanity check - this should always pass
-        assert all(s % factor == 0 for s in sizes_send), "Size does not divide evenly"
-        assert all(s % factor == 0 for s in sizes_rec), "Size does not divide evenly"
-        sizes_send1 = [s // factor for s in sizes_send]
-        sizes_rec1 = [s // factor for s in sizes_rec]
-
-        # now send the same data, but with the adjusted size+datatype (output is identical)
-        comm.Alltoallv(
-            (fullinput, sizes_send1, dtype1), (fulloutput, sizes_rec1, dtype1)
-        )
-    else:
-        comm.Alltoallv((fullinput, sizes_send, dtype), (fulloutput, sizes_rec, dtype))
+    # NOTE: The custom MPI data type is being used below even when the number of elements
+    # doesn't exceed the limit which can be sent in a single MPI operation. See issue #274.
+
+    # find the dim which is equal in all arrays to send/receive
+    dim0s = [s[0] for s in shapes_send] + [s[0] for s in shapes_rec]
+    dim1s = [s[1] for s in shapes_send] + [s[1] for s in shapes_rec]
+    dim2s = [s[2] for s in shapes_send] + [s[2] for s in shapes_rec]
+    dim0equal = all(s == dim0s[0] for s in dim0s)
+    dim1equal = all(s == dim1s[1] for s in dim1s)
+    dim2equal = all(s == dim2s[2] for s in dim2s)
+    assert (
+        dim0equal or dim1equal or dim2equal
+    ), "At least one dimension of the input arrays must be of same size"
+
+    # create a new contiguous MPI datatype by repeating the input type by this common length
+    factor = (
+        arrays[0].shape[0]
+        if dim0equal
+        else arrays[0].shape[1]
+        if dim1equal
+        else arrays[0].shape[2]
+    )
+    dtype1 = dtype.Create_contiguous(factor).Commit()
+    # sanity check - this should always pass
+    assert all(s % factor == 0 for s in sizes_send), "Size does not divide evenly"
+    assert all(s % factor == 0 for s in sizes_rec), "Size does not divide evenly"
+    sizes_send1 = [s // factor for s in sizes_send]
+    sizes_rec1 = [s // factor for s in sizes_rec]
+
+    # now send the same data, but with the adjusted size+datatype (output is identical)
+    comm.Alltoallv(
+        (fullinput, sizes_send1, dtype1), (fulloutput, sizes_rec1, dtype1)
+    )
 
     # build list of output arrays
     cumsizes = np.cumsum(sizes_rec)
diff --git a/httomo/globals.py b/httomo/globals.py
index 124b463e5..af7e6cdc1 100644
--- a/httomo/globals.py
+++ b/httomo/globals.py
@@ -6,3 +6,5 @@
 run_out_dir: os.PathLike = Path('.')
 logger: Optional[Logger] = None
 gpu_id: int = -1
+# maximum slices to use in CPU-only section
+MAX_CPU_SLICES: int = 64
\ No newline at end of file
diff --git a/httomo/loaders/__init__.py b/httomo/loaders/__init__.py
new file mode 100644
index 000000000..c2b5963a4
--- /dev/null
+++ b/httomo/loaders/__init__.py
@@ -0,0 +1,44 @@
+from pathlib import Path
+from typing import Optional
+from mpi4py import MPI
+
+from httomo.darks_flats import DarksFlatsFileConfig
+from httomo.loaders.standard_tomo_loader import StandardLoaderWrapper
+from httomo.loaders.types import AnglesConfig
+from httomo.preview import PreviewConfig
+from httomo.runner.loader import LoaderInterface
+from httomo.runner.methods_repository_interface import MethodRepository
+
+
+def make_loader(
+    repo: MethodRepository,
+    module_path: str,
+    method_name: str,
+    in_file: Path,
+    data_path: str,
+    image_key_path: Optional[str],
+    angles: AnglesConfig,
+    darks: DarksFlatsFileConfig,
+    flats: DarksFlatsFileConfig,
+    preview: PreviewConfig,
+    comm: MPI.Comm,
+) -> LoaderInterface:
+    """Produces a loader interface. Only StandardTomoWrapper is supported right now,
+    and this method has been added for backwards compatibility. Supporting other loaders
+    is a topic that still needs to be explored."""
+
+    if "standard_tomo" not in method_name:
+        raise NotImplementedError(
+            "Only the standard_tomo loader is currently supported"
+        )
+
+    return StandardLoaderWrapper(
+        comm=comm,
+        in_file=in_file,
+        data_path=data_path,
+        image_key_path=image_key_path,
+        darks=darks,
+        flats=flats,
+        angles=angles,
+        preview=preview,
+    )
diff --git a/httomo/loaders/standard_tomo_loader.py b/httomo/loaders/standard_tomo_loader.py
new file mode 100644
index 000000000..7a1e3e604
--- /dev/null
+++ b/httomo/loaders/standard_tomo_loader.py
@@ -0,0 +1,297 @@
+import weakref
+from pathlib import Path
+from typing import Literal, Optional, Tuple
+
+import h5py
+import numpy as np
+from mpi4py import MPI
+
+from httomo.darks_flats import DarksFlatsFileConfig, get_darks_flats
+from httomo.loaders.types import AnglesConfig, UserDefinedAngles
+from httomo.preview import Preview, PreviewConfig
+from httomo.runner.auxiliary_data import AuxiliaryData
+from httomo.runner.dataset import DataSetBlock
+from httomo.runner.dataset_store_interfaces import DataSetSource
+from httomo.runner.loader import LoaderInterface
+from httomo.utils import Pattern, log_once
+
+
+class StandardTomoLoader(DataSetSource):
+    """
+    Loads an individual block at a time from raw data instead of an entire chunk.
+    """
+
+    def __init__(
+        self,
+        in_file: Path,
+        data_path: str,
+        image_key_path: Optional[str],
+        darks: DarksFlatsFileConfig,
+        flats: DarksFlatsFileConfig,
+        angles: AnglesConfig,
+        preview_config: PreviewConfig,
+        slicing_dim: Literal[0, 1, 2],
+        comm: MPI.Comm,
+    ) -> None:
+        if slicing_dim != 0:
+            raise NotImplementedError("Only slicing dim 0 is currently supported")
+
+        self._in_file = in_file
+        self._data_path = data_path
+        self._image_key_path = image_key_path
+        self._angles = angles
+        self._slicing_dim = slicing_dim
+        self._comm = comm
+        self._h5file = h5py.File(in_file, "r")
+        self._data: h5py.Dataset = self._get_data()
+        self._preview = Preview(
+            preview_config=preview_config,
+            dataset=self._data,
+            image_key=(
+                self._h5file[image_key_path] if image_key_path is not None else None
+            ),
+        )
+
+        self._data_indices = self._preview.data_indices
+        self._global_shape = self._preview.global_shape
+        self._data_offset = (
+            self._data_indices[0],
+            self._preview.config.detector_y.start,
+            self._preview.config.detector_x.start,
+        )
+
+        chunk_index_slicing_dim = self._calculate_chunk_index_slicing_dim(
+            comm.rank,
+            comm.size,
+        )
+        next_process_chunk_index_slicing_dim = self._calculate_chunk_index_slicing_dim(
+            comm.rank + 1,
+            comm.size,
+        )
+
+        self._chunk_index = self._calculate_chunk_index(chunk_index_slicing_dim)
+        self._chunk_shape = self._calculate_chunk_shape(
+            chunk_index_slicing_dim,
+            next_process_chunk_index_slicing_dim,
+        )
+
+        self._aux_data = self._setup_aux_data(darks, flats)
+        self._log_info()
+        weakref.finalize(self, self.finalize)
+
+    @property
+    def dtype(self) -> np.dtype:
+        return self._data.dtype
+
+    @property
+    def flats(self) -> np.ndarray:
+        return self._aux_data.get_flats()
+
+    @property
+    def darks(self) -> np.ndarray:
+        return self._aux_data.get_darks()
+
+    @property
+    def slicing_dim(self) -> Literal[0, 1, 2]:
+        return self._slicing_dim
+
+    @property
+    def global_shape(self) -> Tuple[int, int, int]:
+        return self._global_shape
+
+    @property
+    def global_index(self) -> Tuple[int, int, int]:
+        return self._chunk_index
+
+    def _calculate_chunk_index_slicing_dim(
+        self,
+        rank: int,
+        nprocs: int,
+    ) -> int:
+        """
+        Calculate the index of the chunk that is associated with the given MPI process in the
+        slicing dimension
+        """
+        return round((len(self._data_indices) / nprocs) * rank)
+
+    # TODO: Assume projection slice dim for now, and therefore assume chunk index element
+    # ordering
+    def _calculate_chunk_index(
+        self,
+        chunk_index_slicing_dim: int,
+    ) -> Tuple[int, int, int]:
+        """
+        Calculates index of chunk relative to the previewed data
+        """
+        return (chunk_index_slicing_dim, 0, 0)
+
+    @property
+    def chunk_shape(self) -> Tuple[int, int, int]:
+        return self._chunk_shape
+
+    # TODO: Assume projection slice dim for now, and therefore assume chunk shape element
+    # ordering
+    # TODO: Assume no previewing/cropping in angles dimension
+    def _calculate_chunk_shape(
+        self,
+        current_proc_chunk_index: int,
+        next_proc_chunk_index: int,
+    ) -> Tuple[int, int, int]:
+        return (
+            next_proc_chunk_index - current_proc_chunk_index,
+            self._global_shape[1],
+            self._global_shape[2],
+        )
+
+    def read_block(self, start: int, length: int) -> DataSetBlock:
+        slices = [
+            slice(
+                self._data_offset[0] + self._chunk_index[0],
+                self._data_offset[0] + self._chunk_index[0] + self.chunk_shape[0],
+            ),
+            slice(
+                self._data_offset[1] + self._chunk_index[1],
+                self._data_offset[1] + self._chunk_index[1] + self.chunk_shape[1],
+            ),
+            slice(
+                self._data_offset[2] + self._chunk_index[2],
+                self._data_offset[2] + self._chunk_index[2] + self.chunk_shape[2],
+            ),
+        ]
+        slices[self._slicing_dim] = slice(
+            self._data_offset[self._slicing_dim]
+            + self._chunk_index[self._slicing_dim]
+            + start,
+            self._data_offset[self._slicing_dim]
+            + self._chunk_index[self._slicing_dim]
+            + start
+            + length,
+        )
+
+        return DataSetBlock(
+            data=self._data[slices[0], slices[1], slices[2]],
+            aux_data=self._aux_data,
+            slicing_dim=self._slicing_dim,
+            block_start=start,
+            chunk_start=self._chunk_index[self._slicing_dim],
+            global_shape=self._global_shape,
+            chunk_shape=self._chunk_shape,
+        )
+
+    def _get_angles(self) -> np.ndarray:
+        if isinstance(self._angles, UserDefinedAngles):
+            return np.linspace(
+                self._angles.start_angle,
+                self._angles.stop_angle,
+                self._angles.angles_total,
+            )
+
+        return self._h5file[self._angles.data_path][...]
+
+    def finalize(self):
+        self._h5file.close()
+
+    def _get_data(self) -> h5py.Dataset:
+        return self._h5file[self._data_path]
+
+    @property
+    def aux_data(self) -> AuxiliaryData:
+        return self._aux_data
+
+    def _setup_aux_data(
+        self,
+        darks_config: DarksFlatsFileConfig,
+        flats_config: DarksFlatsFileConfig,
+    ) -> AuxiliaryData:
+        angles_arr = np.deg2rad(self._get_angles())
+        darks_arr, flats_arr = get_darks_flats(
+            darks_config,
+            flats_config,
+            self._preview.config,
+        )
+        return AuxiliaryData(angles=angles_arr, darks=darks_arr, flats=flats_arr)
+
+    def _log_info(self) -> None:
+        log_once(
+            f"The full dataset shape is {self._data.shape}",
+            comm=self._comm,
+        )
+        log_once(
+            f"Loading data: {self._in_file}",
+            comm=self._comm,
+        )
+        log_once(
+            f"Path to data: {self._data_path}",
+            comm=self._comm,
+        )
+        log_once(
+            (
+                "Preview: ("
+                f"{self._preview.config.angles.start}:{self._preview.config.angles.stop}, "
+                f"{self._preview.config.detector_y.start}:{self._preview.config.detector_y.stop}, "
+                f"{self._preview.config.detector_x.start}:{self._preview.config.detector_x.stop}"
+                ")"
+            ),
+            comm=self._comm,
+        )
+        log_once(
+            f"Data shape is {self._global_shape} of type {self._data.dtype}",
+            comm=self._comm,
+        )
+
+
+class StandardLoaderWrapper(LoaderInterface):
+    def __init__(
+        self,
+        comm: MPI.Comm,
+        # parameters that should be adjustable from YAML
+        in_file: Path,
+        data_path: str,
+        image_key_path: Optional[str],
+        darks: DarksFlatsFileConfig,
+        flats: DarksFlatsFileConfig,
+        angles: AnglesConfig,
+        preview: PreviewConfig,
+    ):
+        self.pattern = Pattern.projection
+        self.method_name = "standard_tomo"
+        self.package_name = "httomo"
+        self._detector_x: int = 0
+        self._detector_y: int = 0
+        self._angles_total: int = 0
+        self.comm = comm
+        self.in_file = in_file
+        self.data_path = data_path
+        self.image_key_path = image_key_path
+        self.darks = darks
+        self.flats = flats
+        self.angles = angles
+        self.preview = preview
+
+    def make_data_source(self) -> DataSetSource:
+        assert self.pattern in [Pattern.sinogram, Pattern.projection]
+        loader = StandardTomoLoader(
+            in_file=self.in_file,
+            data_path=self.data_path,
+            image_key_path=self.image_key_path,
+            darks=self.darks,
+            flats=self.flats,
+            angles=self.angles,
+            preview_config=self.preview,
+            slicing_dim=1 if self.pattern == Pattern.sinogram else 0,
+            comm=self.comm,
+        )
+        (self._angles_total, self._detector_y, self._detector_x) = loader.global_shape
+        return loader
+
+    @property
+    def detector_x(self) -> int:
+        return self._detector_x
+
+    @property
+    def detector_y(self) -> int:
+        return self._detector_y
+
+    @property
+    def angles_total(self) -> int:
+        return self._angles_total
diff --git a/httomo/loaders/types.py b/httomo/loaders/types.py
new file mode 100644
index 000000000..a54a29662
--- /dev/null
+++ b/httomo/loaders/types.py
@@ -0,0 +1,14 @@
+from typing import NamedTuple, TypeAlias, Union
+
+
+class RawAngles(NamedTuple):
+    data_path: str
+
+
+class UserDefinedAngles(NamedTuple):
+    start_angle: int
+    stop_angle: int
+    angles_total: int
+
+
+AnglesConfig: TypeAlias = Union[RawAngles, UserDefinedAngles]
diff --git a/httomo/logger.py b/httomo/logger.py
index f98daf75e..c6c261392 100644
--- a/httomo/logger.py
+++ b/httomo/logger.py
@@ -9,7 +9,7 @@ def setup_logger(out_dir: Optional[os.PathLike]):
     out_path = Path(out_dir)
     
     # Create timestamped output directory
-    Path.mkdir(out_path)
+    Path.mkdir(out_path, exist_ok=True)
 
     # Create empty `user.log` file
     user_log_path = out_path / "user.log"
diff --git a/httomo/method_wrappers/__init__.py b/httomo/method_wrappers/__init__.py
new file mode 100644
index 000000000..61c2029af
--- /dev/null
+++ b/httomo/method_wrappers/__init__.py
@@ -0,0 +1,78 @@
+from httomo.runner.method_wrapper import MethodWrapper
+from httomo.runner.methods_repository_interface import MethodRepository
+
+from .generic import GenericMethodWrapper
+
+# import all other wrappers to make sure they are available to the factory function
+# (add imports here when createing new wrappers)
+import httomo.method_wrappers.datareducer
+import httomo.method_wrappers.dezinging
+import httomo.method_wrappers.images
+import httomo.method_wrappers.reconstruction
+import httomo.method_wrappers.rotation
+import httomo.method_wrappers.stats_calc
+import httomo.method_wrappers.save_intermediate
+
+from mpi4py.MPI import Comm
+from typing import Dict, Optional
+
+
+def make_method_wrapper(
+    method_repository: MethodRepository,
+    module_path: str,
+    method_name: str,
+    comm: Comm,
+    save_result: Optional[bool] = None,
+    output_mapping: Dict[str, str] = {},
+    **kwargs,
+) -> MethodWrapper:
+    """Factory function to generate the appropriate wrapper based on the module
+    path and method name. Clients do not need to be concerned about which particular
+    derived class is returned.
+
+    Parameters
+    ----------
+
+    method_repository: MethodRepository
+        Repository of methods that we can use the query properties
+    module_path: str
+        Path to the module where the method is in python notation, e.g. "httomolibgpu.prep.normalize"
+    method_name: str
+        Name of the method (function within the given module)
+    comm: Comm
+        MPI communicator object
+    save_result: Optional[bool]
+            Should the method's result be saved to an intermediate h5 file? If not given (or None),
+            it queries the method database for the default value.
+    output_mapping: Dict[str, str]
+        A dictionary mapping output names to translated ones. The side outputs will be renamed
+        as specified, if the parameter is given. If not, no side outputs will be passed on.
+    kwargs:
+        Arbitrary keyword arguments that get passed to the method as parameters.
+
+    Returns
+    -------
+
+    MethodWrapper
+        An instance of a wrapper class
+    """
+
+    # go throw all subclasses of GenericMethodWrapper and see which one should be instantiated,
+    # based on module path and method name
+    cls: type[GenericMethodWrapper] = GenericMethodWrapper
+    for c in GenericMethodWrapper.__subclasses__():
+        if c.should_select_this_class(module_path, method_name):
+            assert cls == GenericMethodWrapper, (
+                f"The values returned from should_select_this_class('{module_path}', '{method_name}')"
+                + f" are ambigious between {c.__name__} and {cls.__name__}"
+            )
+            cls = c
+    return cls(
+        method_repository=method_repository,
+        module_path=module_path,
+        method_name=method_name,
+        comm=comm,
+        save_result=save_result,
+        output_mapping=output_mapping,
+        **kwargs,
+    )
diff --git a/httomo/method_wrappers/datareducer.py b/httomo/method_wrappers/datareducer.py
new file mode 100644
index 000000000..f2b25c760
--- /dev/null
+++ b/httomo/method_wrappers/datareducer.py
@@ -0,0 +1,50 @@
+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
+from typing import Dict, Optional
+
+
+class DatareducerWrapper(GenericMethodWrapper):
+    """Wraps the data_reducer method, to be applied to flats and darks only.
+    The method is sequentially applied to each dataset.
+    """
+
+    @classmethod
+    def should_select_this_class(cls, module_path: str, method_name: str) -> bool:
+        return method_name == "data_reducer"
+
+    def __init__(
+        self,
+        method_repository: MethodRepository,
+        module_path: str,
+        method_name: str,
+        comm: Comm,
+        save_result: Optional[bool] = None,
+        output_mapping: Dict[str, str] = {},
+        **kwargs,
+    ):
+        super().__init__(
+            method_repository,
+            module_path,
+            method_name,
+            comm,
+            save_result,
+            output_mapping,
+            **kwargs,
+        )
+        assert (
+            method_name == "data_reducer"
+        ), "Only data_reducer is supported at the moment"
+        self._flats_darks_processed = False
+
+    def execute(self, block: DataSetBlock) -> DataSetBlock:
+        # check if data needs to be transfered host <-> device
+        block = self._transfer_data(block)
+
+        if not self._flats_darks_processed:
+            block.darks = self.method(block.darks, **self._config_params)
+            block.flats = self.method(block.flats, **self._config_params)
+            self._flats_darks_processed = True
+        return block
diff --git a/httomo/method_wrappers/dezinging.py b/httomo/method_wrappers/dezinging.py
new file mode 100644
index 000000000..5dfc1896a
--- /dev/null
+++ b/httomo/method_wrappers/dezinging.py
@@ -0,0 +1,66 @@
+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 mpi4py.MPI import Comm
+from typing import Dict, Optional
+
+from httomo.utils import catch_gputime
+
+
+class DezingingWrapper(GenericMethodWrapper):
+    """Wraps the remove_outlier method, to clean/dezing the data.
+    Note that this method is applied to all elements of the dataset, i.e.
+    data, darks, and flats.
+    """
+
+    @classmethod
+    def should_select_this_class(cls, module_path: str, method_name: str) -> bool:
+        return method_name == "remove_outlier"
+
+    def __init__(
+        self,
+        method_repository: MethodRepository,
+        module_path: str,
+        method_name: str,
+        comm: Comm,
+        save_result: Optional[bool] = None,
+        output_mapping: Dict[str, str] = {},
+        **kwargs,
+    ):
+        super().__init__(
+            method_repository,
+            module_path,
+            method_name,
+            comm,
+            save_result,
+            output_mapping,
+            **kwargs,
+        )
+        assert (
+            method_name == "remove_outlier"
+        ), "Only remove_outlier is supported at the moment"
+        self._flats_darks_processed = False
+
+    def execute(self, block: DataSetBlock) -> DataSetBlock:
+        self._gpu_time_info = GpuTimeInfo()
+        # check if data needs to be transfered host <-> device
+        block = self._transfer_data(block)
+
+        args = self._build_kwargs(self._transform_params(self._config_params), block)
+        # plug in the correct value for axis instead of auto. In case if axis is absent we take
+        # it equal to 0. This is to avoid failure, but (missing) template parameters should be
+        # tested in the yaml checker potentially
+        self._config_params["axis"] = args.get("axis", 0)
+
+        with catch_gputime() as t:
+            block.data = self.method(block.data, **self._config_params)
+            if not self._flats_darks_processed:
+                block.darks = self.method(block.darks, **self._config_params)
+                block.flats = self.method(block.flats, **self._config_params)
+                self._flats_darks_processed = True
+
+        self._gpu_time_info.kernel = t.elapsed
+
+        return block
diff --git a/httomo/method_wrappers/generic.py b/httomo/method_wrappers/generic.py
new file mode 100644
index 000000000..ef10e45a4
--- /dev/null
+++ b/httomo/method_wrappers/generic.py
@@ -0,0 +1,427 @@
+import httomo.globals
+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,
+    MethodParameterDictType,
+    MethodParameterValues,
+    MethodWrapper,
+)
+from httomo.runner.methods_repository_interface import (
+    GpuMemoryRequirement,
+    MethodRepository,
+)
+from httomo.runner.output_ref import OutputRef
+from httomo.utils import catch_gputime, catchtime, gpu_enabled, log_rank, xp
+
+
+import numpy as np
+from mpi4py.MPI import Comm
+
+
+from inspect import Parameter, signature
+from typing import Any, Callable, Dict, List, Literal, Optional, Tuple
+
+
+class GenericMethodWrapper(MethodWrapper):
+    """Defines a generic method backend wrapper in httomo which is used by task runner.
+
+    Method parameters (configuration parameters, usually set by the user) can be set either
+    using keyword arguments to the constructor, or by using conventional dictionary set/get methods
+    like::
+
+        wrapper["parameter"] = value
+    """
+
+    @classmethod
+    def should_select_this_class(cls, module_path: str, method_name: str) -> bool:
+        """Method to dermine if this class should be used for wrapper instantiation,
+        given the module path and method name.
+
+        The make_method_wrapper function will iterate through all subclasses and evaluate this
+        condition in the order of declaration, falling back to GenericMethodWrapper if all
+        evaluate to False.
+
+        Therefore, deriving classes should override this method to indicate the criteria
+        when they should be instantiated.
+        """
+        return False  # pragma: no cover
+
+    def __init__(
+        self,
+        method_repository: MethodRepository,
+        module_path: str,
+        method_name: str,
+        comm: Comm,
+        save_result: Optional[bool] = None,
+        output_mapping: Dict[str, str] = {},
+        **kwargs,
+    ):
+        """Constructs a MethodWrapper for a method located in module_path with the name method_name.
+
+        Parameters
+        ----------
+
+        method_repository: MethodRepository
+            Repository that can be used to build queries about method attributes
+        module_path: str
+            Path to the module where the method is in python notation, e.g. "httomolibgpu.prep.normalize"
+        method_name: str
+            Name of the method (function within the given module)
+        comm: Comm
+            MPI communicator object
+        save_result: Optional[bool]
+            Should the method's result be saved to an intermediate h5 file? If not given (or None),
+            it queries the method database for the default value.
+        output_mapping: Dict[str, str]
+            Mapping of side-output names to new ones. Used for propagating side outputs by name.
+        **kwargs:
+            Additional keyword arguments will be set as configuration parameters for this
+            method. Note: The method must actually accept them as parameters.
+        """
+
+        self._comm = comm
+        self._module_path = module_path
+        self._method_name = method_name
+        from importlib import import_module
+
+        self._module = import_module(module_path)
+        self._method: Callable = getattr(self._module, method_name)
+        # get all the method parameter names, so we know which to set on calling it
+        sig = signature(self._method)
+        self._parameters = [
+            k for k, p in sig.parameters.items() if p.kind != Parameter.VAR_KEYWORD
+        ]
+        self._params_with_defaults = [
+            k for k, p in sig.parameters.items() if p.default != Parameter.empty
+        ]
+        self._has_kwargs = any(
+            p.kind == Parameter.VAR_KEYWORD for p in sig.parameters.values()
+        )
+
+        self.task_id = kwargs.pop("task_id", "")
+
+        # check if the given kwargs are actually supported by the method
+        self._config_params = kwargs
+        self._output_mapping = output_mapping
+        self._check_config_params()
+
+        # assign method properties from the methods repository
+        self._query = method_repository.query(module_path, method_name)
+        self.pattern = self._query.get_pattern()
+        self._output_dims_change = self._query.get_output_dims_change()
+        self._implementation = self._query.get_implementation()
+        self._memory_gpu = self._query.get_memory_gpu_params()
+        self._save_result = (
+            self._query.save_result_default() if save_result is None else save_result
+        )
+
+        if self.is_gpu and not gpu_enabled:
+            raise ValueError("GPU is not available, please use only CPU methods")
+
+        self._side_output: Dict[str, Any] = dict()
+        
+        self._gpu_time_info = GpuTimeInfo()
+
+        if gpu_enabled:
+            self._num_gpus = xp.cuda.runtime.getDeviceCount()
+            _id = httomo.globals.gpu_id
+            self._gpu_id = mpiutil.local_rank % self._num_gpus if _id == -1 else _id
+
+    @property
+    def comm(self) -> Comm:
+        return self._comm
+
+    @property
+    def method(self) -> Callable:
+        return self._method
+
+    @property
+    def parameters(self) -> List[str]:
+        return self._parameters
+
+    @property
+    def memory_gpu(self) -> List[GpuMemoryRequirement]:
+        return self._memory_gpu
+
+    @property
+    def implementation(self) -> Literal["gpu", "cpu", "gpu_cupy"]:
+        return self._implementation
+
+    @property
+    def output_dims_change(self) -> bool:
+        return self._output_dims_change
+
+    @property
+    def save_result(self) -> bool:
+        return self._save_result
+
+    @property
+    def cupyrun(self) -> bool:
+        return self.implementation == "gpu_cupy"
+
+    @property
+    def is_cpu(self) -> bool:
+        return self.implementation == "cpu"
+
+    @property
+    def is_gpu(self) -> bool:
+        return not self.is_cpu
+    
+    @property
+    def gpu_time(self) -> GpuTimeInfo:
+        return self._gpu_time_info
+
+    @property
+    def method_name(self) -> str:
+        return self._method_name
+
+    @property
+    def module_path(self) -> str:
+        return self._module_path
+
+    @property
+    def package_name(self) -> str:
+        return self._module_path.split(".")[0]
+
+    def __getitem__(self, key: str) -> MethodParameterValues:
+        """Get a parameter for the method using dictionary notation (wrapper["param"])"""
+        return self._config_params[key]
+
+    def __setitem__(self, key: str, value: MethodParameterValues):
+        """Set a parameter for the method using dictionary notation (wrapper["param"] = 42)"""
+        self._config_params[key] = value
+        self._check_config_params()
+
+    @property
+    def config_params(self) -> Dict[str, Any]:
+        """Access a copy of the configuration parameters (cannot be modified directly)"""
+        return {**self._config_params}
+
+    def append_config_params(self, params: MethodParameterDictType):
+        """Append configuration parameters to the existing config_params"""
+        self._config_params |= params
+        self._check_config_params()
+
+    def _check_config_params(self):
+        if self._has_kwargs:
+            return
+        for k in self._config_params.keys():
+            if k not in self.parameters:
+                raise ValueError(
+                    f"{self._method_name}: Unsupported keyword argument given: {k}"
+                )
+
+    @property
+    def recon_algorithm(self) -> Optional[str]:
+        """Determine the recon algorithm used, if the method is reconstruction.
+        Otherwise return None."""
+        return None
+
+    def _build_kwargs(
+        self,
+        dict_params: MethodParameterDictType,
+        dataset: Optional[DataSetBlock] = None,
+    ) -> Dict[str, Any]:
+        # first parameter is always the data (if given)
+        ret: Dict[str, Any] = dict()
+        startidx = 0
+        if dataset is not None:
+            ret[self.parameters[startidx]] = dataset.data
+            startidx += 1
+        # other parameters are looked up by name
+        remaining_dict_params = self._resolve_output_refs(dict_params)
+        for p in self.parameters[startidx:]:
+            if dataset is not None and p in dir(dataset):
+                ret[p] = getattr(dataset, p)
+            elif p == "comm":
+                ret[p] = self.comm
+            elif p == "gpu_id":
+                assert gpu_enabled, "methods with gpu_id parameter require GPU support"
+                ret[p] = self._gpu_id
+            elif p == 'axis' and p in remaining_dict_params and remaining_dict_params[p] == 'auto':
+                ret[p] = self.pattern.value
+                pass
+            elif p in remaining_dict_params:
+                ret[p] = remaining_dict_params[p]            
+            elif p in self._params_with_defaults:
+                pass
+            else:
+                raise ValueError(f"Cannot map method parameter {p} to a value")
+            remaining_dict_params.pop(p, None)
+
+        # if method supports kwargs, pass the rest as those
+        if len(remaining_dict_params) > 0 and self._has_kwargs:
+            ret |= remaining_dict_params
+        return ret
+
+    def _resolve_output_refs(self, dict_params: Dict[str, Any]) -> Dict[str, Any]:
+        """Check for OutputRef instances and resolve their value"""
+        ret: Dict[str, Any] = dict()
+        for k, v in dict_params.items():
+            if isinstance(v, OutputRef):
+                v = v.value
+            ret[k] = v
+        return ret
+
+    def execute(self, block: DataSetBlock) -> DataSetBlock:
+        """Execute functions for external packages.
+
+        Developer note: Derived classes may override this function or any of the methods
+        it uses to modify behaviour.
+
+        Parameters
+        ----------
+
+        block: DataSetBlock
+            A numpy or cupy dataset, mutable (method might work in-place).
+
+        Returns
+        -------
+
+        DataSetBlock
+            A CPU or GPU-based dataset object with the output
+        """
+
+        self._gpu_time_info = GpuTimeInfo()
+        block = self._transfer_data(block)
+        with catch_gputime() as t:
+            block = self._preprocess_data(block)
+            args = self._build_kwargs(self._transform_params(self._config_params), block)
+            block = self._run_method(block, args)
+            block = self._postprocess_data(block)
+
+        self._gpu_time_info.kernel = t.elapsed
+
+        return block
+
+    def _run_method(self, block: DataSetBlock, args: Dict[str, Any]) -> DataSetBlock:
+        """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.
+        """
+        if type(ret) != np.ndarray and type(ret) != xp.ndarray:
+            raise ValueError(
+                f"Invalid return type for method {self._method_name} (in module {self._module_path})"
+            )
+        if self._query.swap_dims_on_output():
+            ret = ret.swapaxes(0, 1)
+        input_block.data = ret
+        return input_block
+
+    def get_side_output(self) -> Dict[str, Any]:
+        """Override this method for functions that have a side output. The returned dictionary
+        will be merged with the dict_params parameter passed to execute for all methods that
+        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:
+        if not self.cupyrun:
+            with catchtime() as t:
+                block.to_cpu()
+            self._gpu_time_info.device2host = t.elapsed
+            return block
+
+        assert gpu_enabled, "GPU method used on a system without GPU support"
+
+        xp.cuda.Device(self._gpu_id).use()
+        gpulog_str = f"Using GPU {self._gpu_id} to transfer data of shape {xp.shape(block.data[0])}"
+        log_rank(gpulog_str, comm=self.comm)
+        gpumem_cleanup()
+        with catchtime() as t:
+            block.to_gpu()
+        self._gpu_time_info.host2device = t.elapsed
+        return block
+
+    def _transform_params(
+        self, dict_params: MethodParameterDictType
+    ) -> MethodParameterDictType:
+        """Hook for derived classes, for transforming the names of the possible method parameters
+        dictionary, for example to rename some of them or inspect them in some way"""
+        return dict_params
+
+    def _preprocess_data(self, block: DataSetBlock) -> DataSetBlock:
+        """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:
+        """Hook for derived classes to implement postprocessing steps, after the method has been
+        called"""
+        return block
+
+    def calculate_output_dims(
+        self, non_slice_dims_shape: Tuple[int, int]
+    ) -> Tuple[int, int]:
+        """Calculate the dimensions of the output for this method"""
+        if self.output_dims_change:
+            return self._query.calculate_output_dims(
+                non_slice_dims_shape, **self.config_params
+            )
+
+        return non_slice_dims_shape
+
+    def calculate_max_slices(
+        self,
+        data_dtype: np.dtype,
+        non_slice_dims_shape: Tuple[int, int],
+        available_memory: int,
+    ) -> Tuple[int, int]:
+        """If it runs on GPU, determine the maximum number of slices that can fit in the
+        available memory in bytes, and return a tuple of
+
+        (max_slices, available_memory)
+
+        The available memory may have been adjusted for the methods that follow, in case
+        something persists afterwards.
+        """
+        if self.is_cpu or not gpu_enabled:
+            return int(100e9), available_memory
+
+        # if we have no information, we assume in-place operation with no extra memory
+        if len(self.memory_gpu) == 0:
+            return (
+                int(
+                    available_memory
+                    // (np.prod(non_slice_dims_shape) * data_dtype.itemsize)
+                ),
+                available_memory,
+            )
+
+        # NOTE: This could go directly into the methodquery / method database,
+        # and here we just call calculated_memory_bytes
+        memory_bytes_method = 0
+        for field in self.memory_gpu:
+            subtract_bytes = 0
+            # loop over the dataset names given in the library file and extracting
+            # the corresponding dimensions from the available datasets
+            if field.method == "direct":
+                assert field.multiplier is not None
+                # this calculation assumes a direct (simple) correspondence through multiplier
+                memory_bytes_method += int(
+                    field.multiplier
+                    * np.prod(non_slice_dims_shape)
+                    * data_dtype.itemsize
+                )
+            else:
+                (
+                    memory_bytes_method,
+                    subtract_bytes,
+                ) = self._query.calculate_memory_bytes(
+                    non_slice_dims_shape, data_dtype, **self.config_params
+                )
+
+        if memory_bytes_method == 0:
+            return available_memory - subtract_bytes, available_memory
+
+        return (
+            available_memory - subtract_bytes
+        ) // memory_bytes_method, available_memory
diff --git a/httomo/method_wrappers/images.py b/httomo/method_wrappers/images.py
new file mode 100644
index 000000000..6e527563c
--- /dev/null
+++ b/httomo/method_wrappers/images.py
@@ -0,0 +1,74 @@
+import httomo.globals
+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
+
+
+from mpi4py.MPI import Comm
+
+
+import os
+from typing import Dict, Optional
+
+
+class ImagesWrapper(GenericMethodWrapper):
+    """Wraps image writer methods, which accept numpy (CPU) arrays as input,
+    but don't actually modify the dataset. They write the information to files"""
+
+    @classmethod
+    def should_select_this_class(cls, module_path: str, method_name: str) -> bool:
+        return module_path.endswith(".images")
+
+    def __init__(
+        self,
+        method_repository: MethodRepository,
+        module_path: str,
+        method_name: str,
+        comm: Comm,
+        save_result: Optional[bool] = None,
+        output_mapping: Dict[str, str] = {},
+        out_dir: Optional[os.PathLike] = None,
+        **kwargs,
+    ):
+        super().__init__(
+            method_repository,
+            module_path,
+            method_name,
+            comm,
+            save_result,
+            output_mapping,
+            **kwargs,
+        )
+        self["out_dir"] = out_dir if out_dir is not None else httomo.globals.run_out_dir
+        if "comm_rank" in self.parameters:
+            raise ValueError(
+                "save_to_images with the comm_rank parameter is broken. " +
+                "Please upgrade to the latest version, taking an offset parameter"
+            )
+
+    # 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:
+        self._gpu_time_info = GpuTimeInfo()
+        config_params = self._config_params
+        if "offset" in self.parameters:
+            config_params = {
+                **self._config_params,
+                "offset": block.global_index[_get_slicing_dim(self.pattern) - 1],
+            }
+            
+        args = self._build_kwargs(self._transform_params(config_params), block)
+        if block.is_gpu:
+            with catchtime() as t:
+                # give method a CPU copy of the data
+                args[self.parameters[0]] = xp.asnumpy(block.data)
+            self._gpu_time_info.device2host = t.elapsed
+
+        self.method(**args)
+
+        return block
diff --git a/httomo/method_wrappers/reconstruction.py b/httomo/method_wrappers/reconstruction.py
new file mode 100644
index 000000000..d02edeb8f
--- /dev/null
+++ b/httomo/method_wrappers/reconstruction.py
@@ -0,0 +1,50 @@
+from httomo.method_wrappers.generic import GenericMethodWrapper
+from httomo.runner.dataset import DataSetBlock
+from httomo.runner.method_wrapper import MethodParameterDictType
+
+
+from typing import Any, Dict, Optional
+
+from httomo.utils import Pattern
+
+
+class ReconstructionWrapper(GenericMethodWrapper):
+    """Wraps reconstruction functions, limiting the length of the angles array
+    before calling the method."""
+
+    @classmethod
+    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:
+        # this is essential for the angles cutting below to be valid
+        assert self.pattern == Pattern.sinogram, "reconstruction methods must be sinogram"
+        
+        # for 360 degrees data the angular dimension will be truncated while angles are not.
+        # Truncating angles if the angular dimension has got a different size
+        datashape0 = block.data.shape[0]
+        if datashape0 != len(block.angles_radians):
+            block.angles_radians = block.angles_radians[0:datashape0]
+        self._input_shape = block.data.shape
+        return super()._preprocess_data(block)
+
+    def _build_kwargs(
+        self,
+        dict_params: MethodParameterDictType,
+        dataset: Optional[DataSetBlock] = 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
+        assert (
+            len(self.parameters) >= 2
+        ), "recon methods always take data + angles as the first 2 parameters"
+        updated_params = {**dict_params, self.parameters[1]: dataset.angles_radians}
+        return super()._build_kwargs(updated_params, dataset)
+
+    @property
+    def recon_algorithm(self) -> Optional[str]:
+        assert "center" in self.parameters, (
+            "All recon methods should have a 'center' parameter, but it doesn't seem"
+            + f" to be the case for {self.module_path}.{self.method_name}"
+        )
+        return self._config_params.get("algorithm", None)
diff --git a/httomo/method_wrappers/rotation.py b/httomo/method_wrappers/rotation.py
new file mode 100644
index 000000000..261f220c3
--- /dev/null
+++ b/httomo/method_wrappers/rotation.py
@@ -0,0 +1,219 @@
+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_rank, xp
+
+
+import numpy as np
+from mpi4py import MPI
+from mpi4py.MPI import Comm
+
+
+from typing import Any, Dict, Optional, Tuple, Union
+
+
+class RotationWrapper(GenericMethodWrapper):
+    """Wraps rotation (centering) methods, which output the original dataset untouched,
+    but have a side output for the center of rotation data (handling both 180 and 360
+    degrees scans).
+
+    It wraps the actual algorithm to find the center and does more. In particular:
+    - extracts a single sinogram from the projections set (across all MPI processes)
+    - normalises it
+    - calls the center-finding algorithm on this normalised data slice
+    - outputs the center of rotation as a side output and broadcast the value
+
+    For block-wise processing support, it aggregates the sinogram in-memory in the method
+    until the sinogram is complete for the current process. Then it uses MPI to add the data
+    from the other processes to it.
+    """
+
+    @classmethod
+    def should_select_this_class(cls, module_path: str, method_name: str) -> bool:
+        return module_path.endswith(".rotation")
+
+    def __init__(
+        self,
+        method_repository: MethodRepository,
+        module_path: str,
+        method_name: str,
+        comm: Comm,
+        save_result: Optional[bool] = None,
+        output_mapping: Dict[str, str] = {},
+        **kwargs,
+    ):
+        super().__init__(
+            method_repository,
+            module_path,
+            method_name,
+            comm,
+            save_result,
+            output_mapping,
+            **kwargs,
+        )
+        if self.pattern not in [Pattern.projection]:
+            raise NotImplementedError(
+                "Base method for rotation wrapper work with the projection pattern only"
+            )
+        self.pattern = Pattern.projection
+        self.sino: Optional[np.ndarray] = None
+
+    def _build_kwargs(
+        self,
+        dict_params: MethodParameterDictType,
+        dataset: Optional[DataSetBlock] = None,
+    ) -> Dict[str, Any]:
+        assert (
+            dataset is not None
+        ), "Rotation wrapper cannot work without a dataset input"
+        # if the function needs "ind" argument, and we either don't give it or have it as "mid",
+        # we set it to the mid point of the data dim 1
+        updated_params = dict_params
+        if not "ind" in dict_params or (
+            dict_params["ind"] == "mid" or dict_params["ind"] is None
+        ):
+            updated_params = {**dict_params, "ind": (dataset.shape[1] - 1) // 2}
+        return super()._build_kwargs(updated_params, dataset)
+
+    def _gather_sino_slice(self, global_shape: Tuple[int, int, int]):
+        assert self.sino is not None
+        if self.comm.size == 1:
+            return self.sino
+
+        # now aggregate with MPI
+        sendbuf = self.sino.reshape(self.sino.size)
+        sizes_rec = self.comm.gather(sendbuf.size)
+        if self.comm.rank == 0:
+            recvbuf = np.empty(global_shape[0] * global_shape[2], dtype=np.float32)
+            self.comm.Gatherv(
+                (sendbuf, self.sino.size, MPI.FLOAT),
+                (recvbuf, sizes_rec, MPI.FLOAT),
+                root=0,
+            )
+            return recvbuf.reshape((global_shape[0], global_shape[2]))
+        else:
+            self.comm.Gatherv(
+                (sendbuf, self.sino.size, MPI.FLOAT),
+                (None, sizes_rec, MPI.FLOAT),
+                root=0,
+            )
+            return None
+
+    def _run_method(self, block: DataSetBlock, args: Dict[str, Any]) -> DataSetBlock:
+        """Different CoR estimation methods require different inputs, e.g., the VoCentering
+        method needs the full sinogram, while the PhaseCorrelation method needs specific projections.
+        """
+        res: Optional[Union[tuple, float, np.float32]] = None
+        if self.method.__name__ == "find_center_pc":
+            # initialising proj1 & proj2
+            if block.global_index[block.slicing_dim] == 0:
+                self.proj1 = block.data[0, :, :]  # first frame in the whole dataset
+                if self.cupyrun:
+                    self.proj1 = xp.asnumpy(self.proj1)
+
+            if not block.is_last_in_chunk:
+                return block
+            else:
+                # Get the last frame of the last block for every process.
+                # For the last process we also get the last frame of the last
+                # block which we send/recieve later
+                self.proj2 = block.data[-1, :, :]
+                if self.cupyrun:
+                    self.proj2 = xp.asnumpy(self.proj2)
+
+            if self.comm.size > 1:
+                # Here we send/recieve the proj2 data from the last process only
+                if self.comm.rank == self.comm.size - 1:
+                    self.comm.send(self.proj2, dest=0)
+                if self.comm.rank == 0:
+                    self.proj2 = self.comm.recv(source=self.comm.size - 1)
+
+            # now calculate the center of rotation on rank 0
+            if self.comm.rank == 0:
+                assert self.proj1 is not None
+                assert self.proj2 is not None
+                if self.cupyrun:
+                    with catchtime() as t:
+                        self.proj1 = xp.asarray(self.proj1)
+                        self.proj2 = xp.asarray(self.proj2)
+                    self._gpu_time_info.host2device += t.elapsed
+                args["proj1"] = self.proj1
+                args["proj2"] = self.proj2
+                res = self.method(**args)
+        else:
+            assert "ind" in args
+            slice_for_cor = args["ind"]
+            # append to internal sinogram, until we have the last block
+            if self.sino is None:
+                self.sino = np.empty(
+                    (block.chunk_shape[0], block.chunk_shape[2]), dtype=np.float32
+                )
+            data = block.data[:, slice_for_cor, :]
+            if block.is_gpu:
+                with catchtime() as t:
+                    data = xp.asnumpy(data)
+                self._gpu_time_info.device2host += t.elapsed
+            self.sino[
+                block.chunk_index[0] : block.chunk_index[0] + block.shape[0], :
+            ] = data
+
+            if not block.is_last_in_chunk:  # exit if we didn't process all blocks yet
+                return block
+
+            sino_slice = self._gather_sino_slice(block.global_shape)
+
+            # now calculate the center of rotation on rank 0
+            if self.comm.rank == 0:
+                sino_slice = self.normalize_sino(
+                    sino_slice,
+                    block.flats[:, slice_for_cor, :],
+                    block.darks[:, slice_for_cor, :],
+                )
+                if self.cupyrun:
+                    with catchtime() as t:
+                        sino_slice = xp.asarray(sino_slice)
+                    self._gpu_time_info.host2device += t.elapsed
+                args["ind"] = 0
+                args[self.parameters[0]] = sino_slice
+                res = self.method(**args)
+        # and broadcast
+        if self.comm.size > 1:
+            res = self.comm.bcast(res, root=0)
+
+        cor_str = f"The center of rotation is {res}"
+        log_rank(cor_str, comm=self.comm)
+        return self._process_return_type(res, block)
+
+    def normalize_sino(
+        self, sino: np.ndarray, flats: Optional[np.ndarray], darks: Optional[np.ndarray]
+    ) -> np.ndarray:
+        flats1d = 1.0 if flats is None else flats.mean(0, dtype=np.float32)
+        darks1d = 0.0 if darks is None else darks.mean(0, dtype=np.float32)
+        denom = flats1d - darks1d
+        sino = sino.astype(np.float32)
+        if np.shape(denom) == tuple():
+            sino -= darks1d  # denominator is always 1
+        elif getattr(denom, "device", None) is not None:
+            # GPU
+            denom[xp.where(denom == 0.0)] = 1.0
+            with catchtime() as t:
+                sino = xp.asarray(sino)
+            self._gpu_time_info.host2device += t.elapsed
+            sino -= darks1d / denom
+        else:
+            # CPU
+            denom[np.where(denom == 0.0)] = 1.0
+            sino -= darks1d / denom
+        return sino[:, np.newaxis, :]
+
+    def _process_return_type(self, ret: Any, input_block: DataSetBlock) -> DataSetBlock:
+        if type(ret) == tuple:
+            # cor, overlap, side, overlap_position - from find_center_360
+            self._side_output["cor"] = float(ret[0])
+            self._side_output["overlap"] = float(ret[1])
+            self._side_output["side"] = int(ret[2]) if ret[2] is not None else None
+            self._side_output["overlap_position"] = float(ret[3])  # float
+        else:
+            self._side_output["cor"] = float(ret)
+        return input_block
diff --git a/httomo/method_wrappers/save_intermediate.py b/httomo/method_wrappers/save_intermediate.py
new file mode 100644
index 000000000..08822beb6
--- /dev/null
+++ b/httomo/method_wrappers/save_intermediate.py
@@ -0,0 +1,79 @@
+import os
+import pathlib
+from typing import Any, Dict, Optional
+import weakref
+from mpi4py.MPI import Comm
+import httomo
+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
+from httomo.utils import catchtime, xp
+
+import h5py
+import numpy as np
+
+
+class SaveIntermediateFilesWrapper(GenericMethodWrapper):
+    
+    @classmethod
+    def should_select_this_class(cls, module_path: str, method_name: str) -> bool:
+        return method_name == "save_intermediate_data"
+    
+    def __init__(self, 
+                 method_repository: MethodRepository, 
+                 module_path: str, 
+                 method_name: str, 
+                 comm: Comm, 
+                 save_result: Optional[bool] = None,
+                 output_mapping: Dict[str, str] = {}, 
+                 out_dir: Optional[os.PathLike] = None,
+                 prev_method: Optional[MethodWrapper] = None,
+                 loader: Optional[LoaderInterface] = None,
+                 **kwargs):
+        super().__init__(method_repository, module_path, method_name, comm, save_result, output_mapping, **kwargs)
+        assert loader is not None
+        self._loader = loader
+        assert prev_method is not None
+
+        filename = f"{prev_method.task_id}-{prev_method.package_name}-{prev_method.method_name}"
+        if prev_method.recon_algorithm is not None:
+            filename += f"-{prev_method.recon_algorithm}"
+        
+        if out_dir is None:
+            out_dir = httomo.globals.run_out_dir
+        assert out_dir is not None
+        self._file = h5py.File(f"{out_dir}/{filename}.h5", "w", driver="mpio", comm=comm)
+        # make sure file gets closed properly
+        weakref.finalize(self, self._file.close)
+        
+    def execute(self, block: DataSetBlock) -> DataSetBlock:
+        # we overwrite the whole execute method here, as we do not need any of the helper
+        # methods from the Generic Wrapper
+        # What we know:
+        # - we do not transfer the dataset as a whole to CPU - only the data and angles locally
+        # (and never back)
+        # - the user does not insert this method - it's automatic - so no config params are
+        # relevant
+        # - we return just the input as it is
+        self._gpu_time_info = GpuTimeInfo()
+        with catchtime() as t:
+            data = block.data if block.is_cpu else xp.asnumpy(block.data)
+        self._gpu_time_info.device2host += t.elapsed
+
+        self._method(
+            data,
+            global_shape=block.global_shape,
+            global_index=block.global_index,
+            file=self._file,
+            path="/data",
+            detector_x=self._loader.detector_x,
+            detector_y=self._loader.detector_y,
+            angles=block.angles,
+        )
+        
+        if block.is_last_in_chunk:
+            self._file.close()
+
+        return block
diff --git a/httomo/method_wrappers/stats_calc.py b/httomo/method_wrappers/stats_calc.py
new file mode 100644
index 000000000..2a3d28e6e
--- /dev/null
+++ b/httomo/method_wrappers/stats_calc.py
@@ -0,0 +1,109 @@
+from httomo.method_wrappers.generic import GenericMethodWrapper
+from httomo.runner.dataset import DataSetBlock
+from httomo.runner.methods_repository_interface import MethodRepository
+from httomo.utils import catchtime, log_rank, xp, gpu_enabled
+
+
+from mpi4py.MPI import Comm
+from mpi4py import MPI
+
+from typing import Any, Dict, Optional, Tuple
+
+
+class StatsCalcWrapper(GenericMethodWrapper):
+    """This class calculates global statistics and deliver a side_output.
+    It also forces to return the original dataset to be passed to the next method.
+
+    Note that the side output is only set once the last block in the chunk has been
+    processed.
+    """
+
+    @classmethod
+    def should_select_this_class(cls, module_path: str, method_name: str) -> bool:
+        return method_name == "calculate_stats"
+
+    def __init__(
+        self,
+        method_repository: MethodRepository,
+        module_path: str,
+        method_name: str,
+        comm: Comm,
+        save_result: Optional[bool] = None,
+        output_mapping: Dict[str, str] = {},
+        **kwargs,
+    ):
+        super().__init__(
+            method_repository,
+            module_path,
+            method_name,
+            comm,
+            save_result,
+            output_mapping,
+            **kwargs,
+        )
+        self._min = float("inf")
+        self._max = float("-inf")
+        self._sum: float = 0.0
+        self._elements: int = 0
+
+    def _transfer_data(self, dataset: DataSetBlock):
+        # don't transfer anything (either way) at this point
+        return dataset
+    
+    def _run_method(self, dataset: DataSetBlock, args: Dict[str, Any]) -> DataSetBlock:
+        # transfer data to GPU if we can / have it available (always faster), 
+        # but don't want to fail if we don't have a GPU (underlying method works for both)
+        # and don't touch original dataset
+        if gpu_enabled and dataset.is_cpu:
+            with catchtime() as t:
+                args[self._parameters[0]] = xp.asarray(dataset.data)
+            self._gpu_time_info.host2device += t.elapsed
+        ret = self._method(**args)
+        return self._process_return_type(ret, dataset)
+        
+
+    def _process_return_type(
+        self, ret: Any, input_block: DataSetBlock
+    ) -> DataSetBlock:
+        assert isinstance(ret, tuple), "expected return type is a tuple"
+        assert len(ret) == 4, "A 4-tuple of stats values is expected"
+
+        self._min = min(self._min, float(ret[0]))
+        self._max = max(self._max, float(ret[1]))
+        self._sum += float(ret[2])
+        self._elements += int(ret[3])
+
+        if input_block.is_last_in_chunk:
+            glob_stats = self._accumulate_chunks()
+            stats_str = f"Global min {glob_stats[0]}, Global max {glob_stats[1]}, Global mean {glob_stats[2]}"
+            log_rank(stats_str, comm=self.comm)
+            self._side_output["glob_stats"] = glob_stats
+
+        return input_block
+
+    def _accumulate_chunks(self) -> Tuple[float, float, float, int]:
+        # reduce to rank 0 process
+        min_glob = self.comm.reduce(float(self._min), MPI.MIN)
+        max_glob = self.comm.reduce(float(self._max), MPI.MAX)
+        sum_glob = self.comm.reduce(float(self._sum), MPI.SUM)
+        elem_glob = self.comm.reduce(int(self._elements), MPI.SUM)
+
+        glob_stats: Tuple[float, float, float, int] = (0.0, 0.0, 0.0, 0)
+        if self.comm.rank == 0:
+            assert isinstance(min_glob, float)
+            assert isinstance(max_glob, float)
+            assert isinstance(sum_glob, float)
+            assert isinstance(elem_glob, int)
+
+            # calculate (min, max, mean, total_elements) tuple
+            glob_stats = (
+                min_glob,
+                max_glob,
+                sum_glob / elem_glob,
+                elem_glob,
+            )
+
+        # broadcast result
+        glob_stats = self.comm.bcast(glob_stats)
+
+        return glob_stats
diff --git a/httomo/methods.py b/httomo/methods.py
index 9f67896c2..9685c3e7e 100644
--- a/httomo/methods.py
+++ b/httomo/methods.py
@@ -1,34 +1,79 @@
+import pathlib
 from typing import Tuple
 import numpy as np
-from mpi4py import MPI
-from mpi4py.MPI import Comm
+import h5py
 
-__all__ = [
-    "calculate_stats",
-]
+from httomo.runner.dataset import DataSetBlock
+from httomo.utils import xp
+
+__all__ = ["calculate_stats", "save_intermediate_data"]
+
+
+def calculate_stats(
+    data: np.ndarray,
+) -> Tuple[float, float, float, int]:
+    """Calculating the statistics of the given array
 
-def calculate_stats(data: np.ndarray, comm: Comm) -> Tuple[float, float, float, int]:
-    """Calculating the global statistics of the given array
-    
     Args:
         data: (np.ndarray): a numpy array
-        comm: The MPI communicator to use.
-        
+
     Returns:
-        tuple[(float, float, float, int)]: (min, max, mean, total_elements)
+        tuple[(float, float, float, int)]: (min, max, sum, total_elements)
     """
-    
-    data = np.nan_to_num(data, copy=False, nan=0.0, posinf=0, neginf=0)
-       
-    # max/min global values
-    minval_glob = comm.allreduce(np.min(data.ravel()), op=MPI.MIN)
-    maxval_glob = comm.allreduce(np.max(data.ravel()), op=MPI.MAX)
-    sum_glob = comm.allreduce(np.sum(data.ravel()), op=MPI.SUM)
-
-    # mean calculation
-    elem_per_process = int(np.prod(np.shape(data)))
-    # the total number of elements in the global data 
-    total_elements = MPI.COMM_WORLD.Get_size()*elem_per_process   
-    cmean_glob =  sum_glob/total_elements
-  
-    return (minval_glob, maxval_glob, cmean_glob, total_elements)
+
+    # do this whereever the data is at the moment (GPU/CPU)
+    if getattr(data, "device", None) is not None:
+        # GPU
+        data = xp.nan_to_num(data, copy=False, nan=0.0, posinf=0, neginf=0)
+    else:
+        # CPU
+        data = np.nan_to_num(data, copy=False, nan=0.0, posinf=0, neginf=0)
+
+    return (float(data.min()), float(data.max()), float(data.sum()), data.size)
+
+
+def save_intermediate_data(
+    data: np.ndarray,
+    global_shape: Tuple[int, int, int],
+    global_index: Tuple[int, int, int],
+    file: h5py.File,
+    path: str,
+    detector_x: int,
+    detector_y: int,
+    angles: np.ndarray,
+) -> None:
+    """Saves intermediate data to a file, including auxiliary"""
+    # only create if not already present - otherwise return existing dataset
+    dataset = file.require_dataset(path, global_shape, data.dtype, exact=True)
+    _save_dataset_data(dataset, data, global_shape, global_index)
+    _save_auxiliary_data(file, angles, detector_x, detector_y)
+
+
+def _save_dataset_data(
+    dataset: h5py.Dataset,
+    data: np.ndarray,
+    global_shape: Tuple[int, int, int],
+    global_index: Tuple[int, int, int],
+):
+    start = np.array(global_index)
+    stop = start + np.array(data.shape)
+    assert getattr(data, "device", None) is None, "data must be on CPU for saving"
+    assert stop[0] <= dataset.shape[0]
+    assert stop[1] <= dataset.shape[1]
+    assert stop[2] <= dataset.shape[2]
+    assert dataset.shape == global_shape
+    dataset[start[0] : stop[0], start[1] : stop[1], start[2] : stop[2]] = data
+
+
+def _save_auxiliary_data(
+    file: h5py.File, angles: np.ndarray, detector_x: int, detector_y: int
+):
+    # only save if not there yet
+    if "/angles" in file:
+        return
+
+    file.create_dataset("/angles", data=angles)
+    file_name = pathlib.Path(file.filename).name
+    file.create_dataset(file_name, data=[0, 0])
+    g1 = file.create_group("data_dims")
+    g1.create_dataset("detector_x_y", data=[detector_x, detector_y])
diff --git a/httomo/methods_database/packages/external/httomolib/1.2/httomolib.yaml b/httomo/methods_database/packages/external/httomolib/1.2/httomolib.yaml
index a1594ade7..b1d4da4e1 100644
--- a/httomo/methods_database/packages/external/httomolib/1.2/httomolib.yaml
+++ b/httomo/methods_database/packages/external/httomolib/1.2/httomolib.yaml
@@ -1,26 +1,30 @@
 misc:
-  corr:
-    inpainting_filter3d:
+  morph:
+    data_reducer:
       pattern: all
-      output_dims_change: False
+      output_dims_change: True
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
   images:
     save_to_images:
       pattern: all
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
   segm:
     binary_thresholding:
       pattern: all
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
 prep:
   phase:
     paganin_filter:
       pattern: projection
       output_dims_change: False
       implementation: cpu
-      memory_gpu: None      
+      memory_gpu: None
+      save_result_default: False
diff --git a/httomo/methods_database/packages/external/httomolibgpu/1.2/httomolibgpu.yaml b/httomo/methods_database/packages/external/httomolibgpu/1.2/httomolibgpu.yaml
index f6254113f..736c4d70a 100644
--- a/httomo/methods_database/packages/external/httomolibgpu/1.2/httomolibgpu.yaml
+++ b/httomo/methods_database/packages/external/httomolibgpu/1.2/httomolibgpu.yaml
@@ -1,17 +1,19 @@
 misc:
   corr:
-    median_filter3d:
+    median_filter:
       pattern: all
-      output_dims_change: False      
+      output_dims_change: False
       implementation: gpu_cupy
+      save_result_default: False
       memory_gpu:
         - datasets: [tomo]
         - multipliers: [2.1]
         - methods: [direct]
-    remove_outlier3d:
+    remove_outlier:
       pattern: all
-      output_dims_change: False      
+      output_dims_change: False
       implementation: gpu_cupy
+      save_result_default: False
       memory_gpu:
         - datasets: [tomo]
         - multipliers: [2.1]
@@ -19,27 +21,44 @@ misc:
   morph:
     sino_360_to_180:
       pattern: sinogram
-      output_dims_change: False      
+      output_dims_change: False
       implementation: gpu_cupy
+      save_result_default: False
       memory_gpu:
         - datasets: [tomo]
         - multipliers: [2.2]
         - methods: [direct]
+    data_resampler:
+      pattern: all
+      output_dims_change: True
+      implementation: gpu_cupy
+  rescale:
+    rescale_to_int:
+      pattern: all
+      output_dims_change: False
+      implementation: gpu_cupy
+      save_result_default: False
+      memory_gpu:
+        - datasets: [tomo]
+        - multipliers: [None]
+        - methods: [module]
 prep:
   normalize:
     normalize:
       pattern: projection
       output_dims_change: False      
       implementation: gpu_cupy
+      save_result_default: False
       memory_gpu:
-        - datasets: [tomo, flats, darks]
-        - multipliers: [2, 1.075, 1.075]
-        - methods: [direct, direct, direct]
+        - datasets: [tomo]
+        - multipliers: [None]
+        - methods: [module]
   phase:
     paganin_filter_tomopy:
       pattern: projection
       output_dims_change: False      
       implementation: gpu_cupy
+      save_result_default: False
       memory_gpu:
         - datasets: [tomo]
         - multipliers: [None]
@@ -48,6 +67,7 @@ prep:
       pattern: projection
       output_dims_change: False      
       implementation: gpu_cupy
+      save_result_default: False
       memory_gpu:
         - datasets: [tomo]
         - multipliers: [None]
@@ -57,6 +77,7 @@ prep:
       pattern: projection
       output_dims_change: False      
       implementation: gpu_cupy
+      save_result_default: False
       memory_gpu:
         - datasets: [tomo]
         - multipliers: [1.2]
@@ -66,6 +87,7 @@ prep:
       pattern: sinogram
       output_dims_change: False      
       implementation: gpu_cupy
+      save_result_default: False
       memory_gpu:
         - datasets: [tomo]
         - multipliers: [1.17]
@@ -74,16 +96,26 @@ prep:
       pattern: sinogram
       output_dims_change: False
       implementation: gpu_cupy
+      save_result_default: False
       memory_gpu:
         - datasets: [tomo]
         - multipliers: [None]
         - methods: [module]
+    remove_all_stripe:
+      pattern: sinogram
+      output_dims_change: False
+      implementation: gpu_cupy
+      memory_gpu:
+        - datasets: [tomo]
+        - multipliers: [None]
+        - methods: [module]     
 recon:
   algorithm:
     FBP:
       pattern: sinogram
       output_dims_change: True
       implementation: gpu_cupy
+      save_result_default: True
       memory_gpu:
         - datasets: [tomo]
         - multipliers: [None]
@@ -92,32 +124,45 @@ recon:
       pattern: sinogram
       output_dims_change: True
       implementation: gpu_cupy
+      save_result_default: True
       memory_gpu:
         - datasets: [tomo]
-        - multipliers: [8]
-        - methods: [direct]
+        - multipliers: [None]
+        - methods: [module]
     CGLS:
       pattern: sinogram
       output_dims_change: True
       implementation: gpu_cupy
+      save_result_default: True
       memory_gpu:
         - datasets: [tomo]
-        - multipliers: [10]
-        - methods: [direct]
+        - multipliers: [None]
+        - methods: [module]
   rotation:
     find_center_vo:
-      pattern: sinogram
+      pattern: projection
       output_dims_change: False
       implementation: gpu_cupy
+      save_result_default: False
       memory_gpu:
         - datasets: [tomo]
         - multipliers: [0]
         - methods: [direct]
     find_center_360:
-      pattern: sinogram
+      pattern: projection
+      output_dims_change: False
+      implementation: gpu_cupy
+      save_result_default: False
+      memory_gpu:
+        - datasets: [tomo]
+        - multipliers: [0]
+        - methods: [direct]
+    find_center_pc:
+      pattern: projection
       output_dims_change: False
       implementation: gpu_cupy
+      save_result_default: False
       memory_gpu:
         - datasets: [tomo]
         - multipliers: [0]
-        - methods: [direct]
\ No newline at end of file
+        - methods: [direct]
diff --git a/tests/ui/__init__.py b/httomo/methods_database/packages/external/httomolibgpu/supporting_funcs/misc/__init__.py
similarity index 100%
rename from tests/ui/__init__.py
rename to httomo/methods_database/packages/external/httomolibgpu/supporting_funcs/misc/__init__.py
diff --git a/httomo/methods_database/packages/external/httomolibgpu/supporting_funcs/misc/morph.py b/httomo/methods_database/packages/external/httomolibgpu/supporting_funcs/misc/morph.py
new file mode 100644
index 000000000..ed3cade3c
--- /dev/null
+++ b/httomo/methods_database/packages/external/httomolibgpu/supporting_funcs/misc/morph.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# ---------------------------------------------------------------------------
+# Copyright 2022 Diamond Light Source Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ---------------------------------------------------------------------------
+# Created By  : Tomography Team at DLS <scientificsoftware@diamond.ac.uk>
+# Created Date: 21 September 2023
+# ---------------------------------------------------------------------------
+"""Modules for memory estimation for morph functions"""
+
+import math
+from typing import Tuple
+import numpy as np
+
+__all__ = [
+    "_calc_memory_bytes_data_resampler",
+    "_calc_output_dim_data_resampler",
+]
+
+
+def _calc_output_dim_data_resampler(non_slice_dims_shape, **kwargs):
+    return kwargs["newshape"]
+
+
+def _calc_memory_bytes_data_resampler(
+    non_slice_dims_shape: Tuple[int, int],
+    dtype: np.dtype,
+    **kwargs,
+) -> Tuple[int, int]:
+    newshape = kwargs["newshape"]
+    interpolation = kwargs["interpolation"]
+
+    input_size = np.prod(non_slice_dims_shape) * dtype.itemsize
+    xi = 2 * np.prod(newshape) * dtype.itemsize
+    output_size = np.prod(newshape) * dtype.itemsize
+
+    # interpolation happens in 2d so we should allocate for it, the exact value is unknown
+    if interpolation == "nearest":
+        interpolator = 3 * (input_size + output_size)
+    if interpolation == "linear":
+        interpolator = 4 * (input_size + output_size)
+
+    tot_memory_bytes = input_size + output_size + interpolator
+    return (tot_memory_bytes, xi)
diff --git a/httomo/methods_database/packages/external/httomolibgpu/supporting_funcs/misc/rescale.py b/httomo/methods_database/packages/external/httomolibgpu/supporting_funcs/misc/rescale.py
new file mode 100644
index 000000000..b44ebf17e
--- /dev/null
+++ b/httomo/methods_database/packages/external/httomolibgpu/supporting_funcs/misc/rescale.py
@@ -0,0 +1,22 @@
+from typing import Tuple
+import numpy as np
+
+__all__ = [
+    "_calc_memory_bytes_rescale_to_int",
+]
+
+
+def _calc_memory_bytes_rescale_to_int(
+        non_slice_dims_shape: Tuple[int, int],
+        dtype: np.dtype,
+        **kwargs,
+) -> Tuple[int, int]:
+    bits: int = kwargs["bits"]
+    if bits == 8:
+        itemsize = 1
+    elif bits == 16:
+        itemsize = 2
+    else:
+        itemsize = 4
+    safety = 128
+    return (int(np.prod(non_slice_dims_shape)) * (dtype.itemsize + itemsize) + safety, 0)
diff --git a/httomo/methods_database/packages/external/httomolibgpu/supporting_funcs/prep/normalize.py b/httomo/methods_database/packages/external/httomolibgpu/supporting_funcs/prep/normalize.py
new file mode 100644
index 000000000..261ea6380
--- /dev/null
+++ b/httomo/methods_database/packages/external/httomolibgpu/supporting_funcs/prep/normalize.py
@@ -0,0 +1,23 @@
+from typing import Tuple
+import numpy as np
+
+
+__all__ = [
+    "_calc_memory_bytes_normalize",
+]
+
+def _calc_memory_bytes_normalize(
+        non_slice_dims_shape: Tuple[int, int],
+        dtype: np.dtype,
+        **kwargs,
+) -> Tuple[int, int]:
+     # this function changes the data type
+    in_slice_mem = np.prod(non_slice_dims_shape) * dtype.itemsize
+    out_slice_mem = np.prod(non_slice_dims_shape) * np.float32().itemsize
+    
+    # fixed cost for keeping mean of flats and darks
+    mean_mem = int(np.prod(non_slice_dims_shape) * np.float32().itemsize * 2)
+
+    tot_memory_bytes = int(in_slice_mem + out_slice_mem)
+
+    return (tot_memory_bytes, mean_mem)
\ No newline at end of file
diff --git a/httomo/methods_database/packages/external/httomolibgpu/supporting_funcs/prep/phase.py b/httomo/methods_database/packages/external/httomolibgpu/supporting_funcs/prep/phase.py
index bddebe9fb..c19108af0 100644
--- a/httomo/methods_database/packages/external/httomolibgpu/supporting_funcs/prep/phase.py
+++ b/httomo/methods_database/packages/external/httomolibgpu/supporting_funcs/prep/phase.py
@@ -91,7 +91,7 @@ def _calc_memory_bytes_paganin_filter_tomopy(
     filter_size = grid_size
     res_slice = grid_size
     
-    tot_memory_bytes = int(input_size + in_slice_size + out_slice_size + 2*complex_slice + 0.5*fftplan_slice + res_slice)
+    tot_memory_bytes = int(input_size + in_slice_size + out_slice_size + 2*complex_slice + 0.5*fftplan_slice + 2*res_slice)
     subtract_bytes = int(filter_size + grid_size)
 
     return (tot_memory_bytes, subtract_bytes)
diff --git a/httomo/methods_database/packages/external/httomolibgpu/supporting_funcs/prep/stripe.py b/httomo/methods_database/packages/external/httomolibgpu/supporting_funcs/prep/stripe.py
index 148bacaae..2909d6df4 100644
--- a/httomo/methods_database/packages/external/httomolibgpu/supporting_funcs/prep/stripe.py
+++ b/httomo/methods_database/packages/external/httomolibgpu/supporting_funcs/prep/stripe.py
@@ -27,20 +27,42 @@
 
 __all__ = [
     "_calc_memory_bytes_remove_stripe_ti",
+    "_calc_memory_bytes_remove_all_stripe",
 ]
 
+
 def _calc_memory_bytes_remove_stripe_ti(
-        non_slice_dims_shape: Tuple[int, int],
-        dtype: np.dtype,
-        **kwargs,
+    non_slice_dims_shape: Tuple[int, int],
+    dtype: np.dtype,
+    **kwargs,
 ) -> Tuple[int, int]:
     # This is admittedly a rough estimation, but it should be about right
     gamma_mem = non_slice_dims_shape[1] * np.float64().itemsize
-    
+
     in_slice_mem = np.prod(non_slice_dims_shape) * dtype.itemsize
     slice_mean_mem = non_slice_dims_shape[1] * dtype.itemsize * 2
     slice_fft_plan_mem = slice_mean_mem * 3.5
     extra_temp_mem = slice_mean_mem * 8
 
-    tot_memory_bytes = in_slice_mem + slice_mean_mem + slice_fft_plan_mem + extra_temp_mem
+    tot_memory_bytes = int(
+        in_slice_mem + slice_mean_mem + slice_fft_plan_mem + extra_temp_mem
+    )
     return (tot_memory_bytes, gamma_mem)
+
+
+def _calc_memory_bytes_remove_all_stripe(
+    non_slice_dims_shape: Tuple[int, int],
+    dtype: np.dtype,
+    **kwargs,
+) -> Tuple[int, int]:
+    # Extremely memory hungry function but it works slice-by-slice so
+    # we need to compensate for that.
+
+    input_size = np.prod(non_slice_dims_shape) * dtype.itemsize
+    output_size = np.prod(non_slice_dims_shape) * dtype.itemsize
+
+    methods_memory_allocations = int(30 * input_size)
+
+    tot_memory_bytes = int(input_size + output_size)
+
+    return (tot_memory_bytes, methods_memory_allocations)
diff --git a/httomo/methods_database/packages/external/httomolibgpu/supporting_funcs/recon/algorithm.py b/httomo/methods_database/packages/external/httomolibgpu/supporting_funcs/recon/algorithm.py
index c511fc34a..2c0347556 100644
--- a/httomo/methods_database/packages/external/httomolibgpu/supporting_funcs/recon/algorithm.py
+++ b/httomo/methods_database/packages/external/httomolibgpu/supporting_funcs/recon/algorithm.py
@@ -27,42 +27,101 @@
 
 __all__ = [
     "_calc_memory_bytes_FBP",
+    "_calc_memory_bytes_SIRT",
+    "_calc_memory_bytes_CGLS",
     "_calc_output_dim_FBP",
+    "_calc_output_dim_SIRT",
+    "_calc_output_dim_CGLS",
 ]
 
+
 def __calc_output_dim_recon(non_slice_dims_shape, **kwargs):
-    """Function to calculate output dimensions for all reconstructors. 
+    """Function to calculate output dimensions for all reconstructors.
     The change of the dimension depends either on the user-provided "recon_size"
     parameter or taken as the size of the horizontal detector (default).
 
     """
     DetectorsLengthH = non_slice_dims_shape[1]
-    recon_size = kwargs['recon_size']
+    recon_size = kwargs["recon_size"]
     if recon_size is None:
         recon_size = DetectorsLengthH
     output_dims = (recon_size, recon_size)
     return output_dims
 
+
 def _calc_output_dim_FBP(non_slice_dims_shape, **kwargs):
     return __calc_output_dim_recon(non_slice_dims_shape, **kwargs)
 
+
+def _calc_output_dim_SIRT(non_slice_dims_shape, **kwargs):
+    return __calc_output_dim_recon(non_slice_dims_shape, **kwargs)
+
+
+def _calc_output_dim_CGLS(non_slice_dims_shape, **kwargs):
+    return __calc_output_dim_recon(non_slice_dims_shape, **kwargs)
+
+
 def _calc_memory_bytes_FBP(
-        non_slice_dims_shape: Tuple[int, int],
-        dtype: np.dtype,
-        **kwargs,
-) -> Tuple[int, int]:    
+    non_slice_dims_shape: Tuple[int, int],
+    dtype: np.dtype,
+    **kwargs,
+) -> Tuple[int, int]:
     DetectorsLengthH = non_slice_dims_shape[1]
     # calculate the output shape
-    output_dims = _calc_output_dim_FBP(non_slice_dims_shape,  **kwargs)
-    
+    output_dims = _calc_output_dim_FBP(non_slice_dims_shape, **kwargs)
+
     in_slice_size = np.prod(non_slice_dims_shape) * dtype.itemsize
-    filter_size = (DetectorsLengthH//2+1) * np.float32().itemsize
-    freq_slice = non_slice_dims_shape[0] * (DetectorsLengthH//2+1) * np.complex64().itemsize
+    filter_size = (DetectorsLengthH // 2 + 1) * np.float32().itemsize
+    freq_slice = (
+        non_slice_dims_shape[0] * (DetectorsLengthH // 2 + 1) * np.complex64().itemsize
+    )
     fftplan_size = freq_slice * 2
     filtered_in_data = np.prod(non_slice_dims_shape) * np.float32().itemsize
 
-    # astra backprojection will generate an output array 
-    astra_out_size = (np.prod(output_dims) * np.float32().itemsize)
+    # astra backprojection will generate an output array
+    astra_out_size = np.prod(output_dims) * np.float32().itemsize
 
-    tot_memory_bytes = int(2*in_slice_size + filtered_in_data + freq_slice + fftplan_size + 2.5*astra_out_size)
+    tot_memory_bytes = int(
+        2 * in_slice_size
+        + filtered_in_data
+        + freq_slice
+        + fftplan_size
+        + 3.5 * astra_out_size
+    )
     return (tot_memory_bytes, filter_size)
+
+
+def _calc_memory_bytes_SIRT(
+    non_slice_dims_shape: Tuple[int, int],
+    dtype: np.dtype,
+    **kwargs,
+) -> Tuple[int, int]:
+    DetectorsLengthH = non_slice_dims_shape[1]
+    # calculate the output shape
+    output_dims = _calc_output_dim_SIRT(non_slice_dims_shape, **kwargs)
+
+    in_data_size = np.prod(non_slice_dims_shape) * dtype.itemsize
+    out_data_size = np.prod(output_dims) * dtype.itemsize
+
+    astra_projection = 2.5 * (in_data_size + out_data_size)
+
+    tot_memory_bytes = 2 * in_data_size + 2 * out_data_size + astra_projection
+    return (tot_memory_bytes, 0)
+
+
+def _calc_memory_bytes_CGLS(
+    non_slice_dims_shape: Tuple[int, int],
+    dtype: np.dtype,
+    **kwargs,
+) -> Tuple[int, int]:
+    DetectorsLengthH = non_slice_dims_shape[1]
+    # calculate the output shape
+    output_dims = _calc_output_dim_CGLS(non_slice_dims_shape, **kwargs)
+
+    in_data_size = np.prod(non_slice_dims_shape) * dtype.itemsize
+    out_data_size = np.prod(output_dims) * dtype.itemsize
+
+    astra_projection = 2.5 * (in_data_size + out_data_size)
+
+    tot_memory_bytes = 2 * in_data_size + 2 * out_data_size + astra_projection
+    return (tot_memory_bytes, 0)
diff --git a/httomo/methods_database/packages/external/tomopy/1.14/tomopy.yaml b/httomo/methods_database/packages/external/tomopy/1.14/tomopy.yaml
index ddba1a3c5..0b95105fb 100644
--- a/httomo/methods_database/packages/external/tomopy/1.14/tomopy.yaml
+++ b/httomo/methods_database/packages/external/tomopy/1.14/tomopy.yaml
@@ -6,51 +6,61 @@ sim:
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     add_focal_spot_blur:
       pattern: all
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     add_gaussian:
       pattern: all
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     add_poisson:
       pattern: all
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     add_rings:
       pattern: all
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     add_salt_pepper:
       pattern: all
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     add_zingers:
       pattern: all
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     project:
       pattern: all
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     project2:
       pattern: all
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     project3:
       pattern: all
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
 misc:
   corr:
     adjust_range:
@@ -58,92 +68,110 @@ misc:
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     circ_mask:
       pattern: all
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     gaussian_filter:
       pattern: all
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     median_filter:
       pattern: all
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     median_filter3d:
       pattern: all
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     median_filter_nonfinite:
       pattern: all
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     remove_nan:
       pattern: all
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     remove_neg:
       pattern: all
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     remove_outlier:
       pattern: all
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     remove_outlier1d:
       pattern: all
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     remove_outlier3d:
       pattern: all
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     remove_ring:
       pattern: all
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     sobel_filter:
       pattern: all
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
   morph:
     downsample:
       pattern: all
       output_dims_change: True
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     pad:
       pattern: all
       output_dims_change: True
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     sino_360_to_180:
       pattern: sinogram
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     trim_sinogram:
       pattern: sinogram
       output_dims_change: True
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     upsample:
       pattern: all
       output_dims_change: True
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
 prep:
   alignment:
     add_jitter:
@@ -151,139 +179,166 @@ prep:
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     add_noise:
       pattern: all
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     align_joint:
       pattern: sinogram
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     align_seq:
       pattern: sinogram
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     blur_edges:
       pattern: projection
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     distortion_correction_proj:
       pattern: projection
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     distortion_correction_sino:
       pattern: sinogram
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     scale:
       pattern: projection
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     shift_images:
       pattern: projection
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
   normalize:
     minus_log:
       pattern: projection
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     normalize:
       pattern: projection
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     normalize_roi:
       pattern: projection
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     normalize_bg:
       pattern: projection
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     normalize_nf:
       pattern: projection
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
   phase:
     retrieve_phase:
       pattern: projection
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
   stripe:
     remove_all_stripe:
       pattern: sinogram
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     remove_stripe_fw:
       pattern: sinogram
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     remove_stripe_ti:
       pattern: sinogram
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     remove_stripe_sf:
       pattern: sinogram
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     remove_stripe_based_sorting:
       pattern: sinogram
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     remove_stripe_based_filtering:
       pattern: sinogram
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     remove_stripe_based_fitting:
       pattern: sinogram
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     remove_large_stripe:
       pattern: sinogram
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     remove_dead_stripe:
       pattern: sinogram
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     remove_stripe_based_interpolation:
       pattern: sinogram
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     stripes_detect3d:
       pattern: sinogram
       implementation: cpu
       output_dims_change: False
       memory_gpu: None
+      save_result_default: False
     stripes_mask3d:
       pattern: sinogram
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
 recon:
   algorithm:
     recon:
@@ -291,44 +346,47 @@ recon:
       output_dims_change: True
       implementation: cpu
       memory_gpu: None
+      save_result_default: True
   rotation:
     find_center:
-      pattern: sinogram
+      pattern: projection
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     find_center_pc:
-      pattern: sinogram
+      pattern: projection
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: True
     find_center_vo:
-      pattern: sinogram
-      output_dims_change: False
-      implementation: cpu
-      memory_gpu: None
-    write_center:
-      pattern: sinogram
+      pattern: projection
       output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
   wrappers:
     astra:
       pattern: sinogram
-      output_dims_change: True
+      output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     ufo_fbp:
       pattern: sinogram
-      output_dims_change: True
+      output_dims_change: False
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     ufo_dfi:
       pattern: sinogram
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
     lprec:
       pattern: sinogram
       output_dims_change: True
       implementation: cpu
       memory_gpu: None
+      save_result_default: False
diff --git a/httomo/methods_database/packages/httomo.yaml b/httomo/methods_database/packages/httomo.yaml
index f546bb6bb..590622db1 100644
--- a/httomo/methods_database/packages/httomo.yaml
+++ b/httomo/methods_database/packages/httomo.yaml
@@ -6,9 +6,17 @@ data:
         output_dims_change: False
         implementation: cpu
         memory_gpu: None 
+        save_result_default: False
 methods:  
   calculate_stats:
     pattern: all
     output_dims_change: False
     implementation: cpu
     memory_gpu: None
+    save_result_default: False
+  save_intermediate_data:
+    pattern: all
+    output_dims_change: False
+    implementation: cpu
+    memory_gpu: None
+    save_result_default: False
\ No newline at end of file
diff --git a/httomo/methods_database/query.py b/httomo/methods_database/query.py
index 16e12b346..f1eb2adf5 100644
--- a/httomo/methods_database/query.py
+++ b/httomo/methods_database/query.py
@@ -87,16 +87,15 @@ def __init__(self, module_path: str, method_name: str):
 
     def get_pattern(self) -> Pattern:
         p = get_method_info(self.module_path, self.method_name, "pattern")
+        assert p in ["projection", "sinogram", "all"], (
+            f"The pattern {p} that is listed for the method "
+            f"{self.module_path}.{self.method_name} is invalid."
+        )
         if p == "projection":
             return Pattern.projection
         if p == "sinogram":
             return Pattern.sinogram
-        if p == "all":
-            return Pattern.all
-        raise ValueError(
-            f"The pattern {p} that is listed for the method "
-            f"{self.module_path}.{self.method_name} is invalid."
-        )
+        return Pattern.all
 
     def get_output_dims_change(self) -> bool:
         p = get_method_info(self.module_path, self.method_name, "output_dims_change")
@@ -104,12 +103,18 @@ def get_output_dims_change(self) -> bool:
 
     def get_implementation(self) -> Literal["cpu", "gpu", "gpu_cupy"]:
         p = get_method_info(self.module_path, self.method_name, "implementation")
-        if p not in ["gpu", "gpu_cupy", "cpu"]:
-            raise ValueError(
-                f"The ipmlementation arch {p} listed for method {self.module_path}.{self.method_name} is invalid"
-            )
+        assert p in [
+            "gpu",
+            "gpu_cupy",
+            "cpu",
+        ], f"The implementation arch {p} listed for method {self.module_path}.{self.method_name} is invalid"
         return p
 
+    def save_result_default(self) -> bool:
+        return get_method_info(
+            self.module_path, self.method_name, "save_result_default"
+        )
+
     def get_memory_gpu_params(
         self,
     ) -> List[GpuMemoryRequirement]:
@@ -118,7 +123,7 @@ def get_memory_gpu_params(
             return []
         if type(p) == list:
             # convert to dict first
-            dd = dict()
+            dd: dict = dict()
             for item in p:
                 dd |= item
         else:
diff --git a/httomo/monitors/__init__.py b/httomo/monitors/__init__.py
new file mode 100644
index 000000000..9a1087533
--- /dev/null
+++ b/httomo/monitors/__init__.py
@@ -0,0 +1,23 @@
+from typing import Dict, List, Optional
+from httomo.monitors.aggregate import AggregateMonitoring
+from httomo.monitors.benchmark import BenchmarkMonitoring
+from httomo.monitors.summary import SummaryMonitor
+from httomo.runner.monitoring_interface import MonitoringInterface
+
+
+MONITORS_MAP = {
+    "bench": BenchmarkMonitoring,
+    "summary": SummaryMonitor
+}
+
+def make_monitors(monitor_descriptors: List[str]) -> Optional[MonitoringInterface]:
+    if len(monitor_descriptors) == 0:
+        return None
+    
+    monitors: List[MonitoringInterface] = []
+    for descriptor in monitor_descriptors:
+        if descriptor not in MONITORS_MAP:
+            raise ValueError(f"Unknown monitor '{descriptor}'. Please choose one of {MONITORS_MAP.keys()}")
+        monitors.append(MONITORS_MAP[descriptor]())
+        
+    return AggregateMonitoring(monitors)
\ No newline at end of file
diff --git a/httomo/monitors/aggregate.py b/httomo/monitors/aggregate.py
new file mode 100644
index 000000000..456bc38bc
--- /dev/null
+++ b/httomo/monitors/aggregate.py
@@ -0,0 +1,77 @@
+from typing import List, TextIO, Tuple
+from httomo.runner.monitoring_interface import MonitoringInterface
+
+
+class AggregateMonitoring(MonitoringInterface):
+    def __init__(self, monitors: List[MonitoringInterface]):
+        self._monitors = monitors
+
+    def report_method_block(
+        self,
+        method_name: str,
+        module_path: str,
+        task_id: str,
+        slicing_dim: int,
+        block_dims: Tuple[int, int, int],
+        block_idx_chunk: Tuple[int, int, int],
+        block_idx_global: Tuple[int, int, int],
+        cpu_time: float,
+        gpu_kernel_time: float = 0.0,
+        gpu_h2d_time: float = 0.0,
+        gpu_d2h_time: float = 0.0,
+    ):
+        for m in self._monitors:
+            m.report_method_block(
+                method_name,
+                module_path,
+                task_id,
+                slicing_dim,
+                block_dims,
+                block_idx_chunk,
+                block_idx_global,
+                cpu_time,
+                gpu_kernel_time,
+                gpu_h2d_time,
+                gpu_d2h_time,
+            )
+
+    def report_source_block(
+        self,
+        name: str,
+        first_task_id: str,
+        slicing_dim: int,
+        block_dims: Tuple[int, int, int],
+        block_idx_chunk: Tuple[int, int, int],
+        block_idx_global: Tuple[int, int, int],
+        cpu_time: float,
+    ):
+        for m in self._monitors:
+            m.report_source_block(
+                name, first_task_id, slicing_dim, block_dims, block_idx_chunk, block_idx_global, cpu_time
+            )
+
+    def report_sink_block(
+        self,
+        name: str,
+        last_task_id: str,
+        slicing_dim: int,
+        block_dims: Tuple[int, int, int],
+        block_idx_chunk: Tuple[int, int, int],
+        block_idx_global: Tuple[int, int, int],
+        cpu_time: float,
+    ):
+        for m in self._monitors:
+            m.report_sink_block(
+                name, last_task_id, slicing_dim, block_dims, block_idx_chunk, block_idx_global, cpu_time
+            )
+            
+    def report_total_time(self, cpu_time: float):
+        for m in self._monitors:
+            m.report_total_time(cpu_time)
+    
+
+    def write_results(self, dest: TextIO):
+        for m in self._monitors:
+            m.write_results(dest)
+            dest.writelines("\n")
+            dest.flush()
\ No newline at end of file
diff --git a/httomo/monitors/benchmark.py b/httomo/monitors/benchmark.py
new file mode 100644
index 000000000..9badb66ed
--- /dev/null
+++ b/httomo/monitors/benchmark.py
@@ -0,0 +1,147 @@
+import csv
+from typing import Dict, List, TextIO, Tuple
+from httomo.runner.monitoring_interface import MonitoringInterface
+from mpi4py import MPI
+from collections import OrderedDict
+
+
+class BenchmarkMonitoring(MonitoringInterface):
+    def __init__(self) -> None:
+        self._data: List[Dict[str, str]] = []
+        self._total = 0.0
+        self._comm = MPI.COMM_WORLD
+
+    def report_method_block(
+        self,
+        method_name: str,
+        module_path: str,
+        task_id: str,
+        slicing_dim: int,
+        block_dims: Tuple[int, int, int],
+        block_idx_chunk: Tuple[int, int, int],
+        block_idx_global: Tuple[int, int, int],
+        cpu_time: float,
+        gpu_kernel_time: float = 0.0,
+        gpu_h2d_time: float = 0.0,
+        gpu_d2h_time: float = 0.0
+    ):
+        self._data.append(
+            OrderedDict(
+                [
+                    ("Type", "method"),
+                    ("Rank", str(self._comm.rank)),
+                    ("Name", method_name),
+                    ("Task id", task_id),
+                    ("Module", module_path),
+                    ("Slicing dim", str(slicing_dim)),
+                    ("Block offset (chunk)", str(block_idx_chunk[slicing_dim])),
+                    ("Block offset (global)", str(block_idx_global[slicing_dim])),
+                    ("Block dim z", str(block_dims[0])),
+                    ("Block dim y", str(block_dims[1])),
+                    ("Block dim x", str(block_dims[2])),
+                    ("CPU time", str(cpu_time)),
+                    ("GPU kernel time", str(gpu_kernel_time)),
+                    ("GPU H2D time", str(gpu_h2d_time)),
+                    ("GPU D2H time", str(gpu_d2h_time)),
+                ]
+            )
+        )
+
+    def report_source_block(
+        self,
+        name: str,
+        first_task_id: str,
+        slicing_dim: int,
+        block_dims: Tuple[int, int, int],
+        block_idx_chunk: Tuple[int, int, int],
+        block_idx_global: Tuple[int, int, int],
+        cpu_time: float,
+    ):
+        self._data.append(
+            OrderedDict(
+                [
+                    ("Type", "source"),
+                    ("Rank", str(self._comm.rank)),
+                    ("Name", name),
+                    ("Task id", first_task_id),
+                    ("Module", ""),
+                    ("Slicing dim", str(slicing_dim)),
+                    ("Block offset (chunk)", str(block_idx_chunk[slicing_dim])),
+                    ("Block offset (global)", str(block_idx_global[slicing_dim])),
+                    ("Block dim z", str(block_dims[0])),
+                    ("Block dim y", str(block_dims[1])),
+                    ("Block dim x", str(block_dims[2])),
+                    ("CPU time", str(cpu_time)),
+                    ("GPU kernel time", "0.0"),
+                    ("GPU H2D time", "0.0"),
+                    ("GPU D2H time", "0.0"),
+                ]
+            )
+        )
+
+    def report_sink_block(
+        self,
+        name: str,
+        last_task_id: str,
+        slicing_dim: int,
+        block_dims: Tuple[int, int, int],
+        block_idx_chunk: Tuple[int, int, int],
+        block_idx_global: Tuple[int, int, int],
+        cpu_time: float,
+    ):
+        self._data.append(
+            OrderedDict(
+                [
+                    ("Type", "sink"),
+                    ("Rank", str(self._comm.rank)),
+                    ("Name", name),
+                    ("Task id", last_task_id),
+                    ("Module", ""),
+                    ("Slicing dim", str(slicing_dim)),
+                    ("Block offset (chunk)", str(block_idx_chunk[slicing_dim])),
+                    ("Block offset (global)", str(block_idx_global[slicing_dim])),
+                    ("Block dim z", str(block_dims[0])),
+                    ("Block dim y", str(block_dims[1])),
+                    ("Block dim x", str(block_dims[2])),
+                    ("CPU time", str(cpu_time)),
+                    ("GPU kernel time", "0.0"),
+                    ("GPU H2D time", "0.0"),
+                    ("GPU D2H time", "0.0"),
+                ]
+            )
+        )
+
+    def report_total_time(self, cpu_time: float):
+        self._data.append(
+            OrderedDict(
+                [
+                    ("Type", "total"),
+                    ("Rank", str(self._comm.rank)),
+                    ("Name", ""),
+                    ("Task id", ""),
+                    ("Module", ""),
+                    ("Slicing dim", ""),
+                    ("Block offset (chunk)", ""),
+                    ("Block offset (global)", ""),
+                    ("Block dim z", ""),
+                    ("Block dim y", ""),
+                    ("Block dim x", ""),
+                    ("CPU time", str(cpu_time)),
+                    ("GPU kernel time", "0.0"),
+                    ("GPU H2D time", "0.0"),
+                    ("GPU D2H time", "0.0"),
+                ]
+            )
+        )
+
+    def write_results(self, dest: TextIO):
+        self._aggregate_mpi()
+        if self._comm.rank == 0:
+            writer = csv.DictWriter(dest, fieldnames=self._data[0].keys())
+            writer.writeheader()
+            writer.writerows(self._data)
+        
+    def _aggregate_mpi(self):
+        alldata = self._comm.gather(self._data)
+        if self._comm.rank == 0:
+            self._data = sum(alldata, [])
diff --git a/httomo/monitors/summary.py b/httomo/monitors/summary.py
new file mode 100644
index 000000000..d9789cd33
--- /dev/null
+++ b/httomo/monitors/summary.py
@@ -0,0 +1,102 @@
+from typing import Dict, TextIO, Tuple
+from mpi4py import MPI
+from httomo.runner.monitoring_interface import MonitoringInterface
+
+
+class SummaryMonitor(MonitoringInterface):
+    def __init__(self) -> None:
+        self._methods_cpu = 0.0
+        self._methods_gpu = 0.0
+        self._methods: Dict[str, float] = dict()
+        self._h2d = 0.0
+        self._d2h = 0.0
+        self._sources = 0.0
+        self._sinks = 0.0
+        self._total = 0.0
+        self._total_agg = 0.0
+        self._comm = MPI.COMM_WORLD
+    
+    def report_method_block(
+        self,
+        method_name: str,
+        module_path: str,
+        task_id: str,
+        slicing_dim: int,
+        block_dims: Tuple[int, int, int],
+        block_idx_chunk: Tuple[int, int, int],
+        block_idx_global: Tuple[int, int, int],
+        cpu_time: float,
+        gpu_kernel_time: float = 0.0,
+        gpu_h2d_time: float = 0.0,
+        gpu_d2h_time: float = 0.0        
+    ):
+        if method_name not in self._methods:
+            self._methods[method_name] = 0.0
+        self._methods[method_name] += cpu_time
+        self._methods_cpu += cpu_time
+        self._methods_gpu += gpu_kernel_time
+        self._h2d += gpu_h2d_time
+        self._d2h += gpu_d2h_time
+        
+    def report_source_block(
+        self,
+        name: str,
+        first_task_id: str,
+        slicing_dim: int,
+        block_dims: Tuple[int, int, int],
+        block_idx_chunk: Tuple[int, int, int],
+        block_idx_global: Tuple[int, int, int],
+        cpu_time: float
+    ):
+        self._sources += cpu_time
+        
+    def report_sink_block(
+        self,
+        name: str,
+        last_task_id: str,
+        slicing_dim: int,
+        block_dims: Tuple[int, int, int],
+        block_idx_chunk: Tuple[int, int, int],
+        block_idx_global: Tuple[int, int, int],
+        cpu_time: float
+    ):
+        self._sinks += cpu_time
+        
+    def report_total_time(self, cpu_time: float):
+        self._total = cpu_time
+        
+    def write_results(self, dest: TextIO):
+        self._aggregate_mpi()
+        if self._comm.rank == 0:
+            lines = [
+                f"Summary Statistics (aggregated across {self._comm.size} processes):",
+                f"  Total methods CPU time: {self._methods_cpu:>10.3f}s",
+                f"  Total methods GPU time: {self._methods_gpu:>10.3f}s",
+                f"  Total host2device time: {self._h2d:>10.3f}s",
+                f"  Total device2host time: {self._d2h:>10.3f}s",
+                f"  Total sources time    : {self._sources:>10.3f}s",
+                f"  Total sinks time      : {self._sinks:>10.3f}s",
+                f"  Other overheads       : {self._total_agg - sum([self._methods_cpu, self._sources, self._sinks]):>10.3f}s",
+                f"  ------------------------" + "-" * 15,
+                f"  Total pipeline time   : {self._total_agg:>10.3f}s",
+                f"  Total wall time       : {self._total:>10.3f}s",
+                f"  ------------------------" + "-" * 15,
+                f"Method breakdowns:",
+            ]
+            for k,v in self._methods.items():
+                lines.append(f"  {k:>30s} : {v:>10.3f}s ({v / self._total * 100:>4.1f}%)")
+            dest.write('\n'.join(lines))
+        
+    def _aggregate_mpi(self):
+        self._total_agg = self._total
+        if self._comm.size == 1:
+            return
+        self._methods_cpu = self._comm.reduce(self._methods_cpu, MPI.SUM)
+        self._methods_gpu = self._comm.reduce(self._methods_gpu, MPI.SUM)
+        self._sources = self._comm.reduce(self._sources, MPI.SUM)
+        self._sinks = self._comm.reduce(self._sinks, MPI.SUM)
+        self._h2d = self._comm.reduce(self._h2d, MPI.SUM)
+        self._d2h = self._comm.reduce(self._d2h, MPI.SUM)
+        self._total_agg = self._comm.reduce(self._total_agg, MPI.SUM)
+        for k in self._methods.keys():
+            self._methods[k] = self._comm.reduce(self._methods[k], MPI.SUM)
diff --git a/httomo/preview.py b/httomo/preview.py
new file mode 100644
index 000000000..11f598f22
--- /dev/null
+++ b/httomo/preview.py
@@ -0,0 +1,99 @@
+from typing import List, NamedTuple, Optional, Tuple
+
+import h5py
+import numpy as np
+
+
+class PreviewDimConfig(NamedTuple):
+    start: int
+    stop: int
+
+
+class PreviewConfig(NamedTuple):
+    angles: PreviewDimConfig
+    detector_y: PreviewDimConfig
+    detector_x: PreviewDimConfig
+
+
+class Preview:
+    def __init__(
+        self,
+        preview_config: PreviewConfig,
+        dataset: h5py.Dataset,
+        image_key: Optional[h5py.Dataset],
+    ) -> None:
+        self.config = preview_config
+        self._dataset = dataset
+        self._image_key = image_key
+        self._check_within_data_bounds()
+        self._data_indices: Optional[List[int]] = None
+        self._global_shape: Optional[Tuple[int, int, int]] = None
+
+    def _check_within_data_bounds(self) -> None:
+        shape = self._dataset.shape
+        for i, field in enumerate(self.config._fields):
+            self._check_dimension(
+                name=field,
+                config=getattr(self.config, field),
+                length=shape[i],
+            )
+
+    def _check_dimension(
+        self,
+        name: str,
+        config: PreviewDimConfig,
+        length: int,
+    ) -> None:
+        if config.stop > length:
+            raise ValueError(
+                f"Preview indices in {name} dim exceed bounds of data: "
+                f"start={config.start}, stop={config.stop}"
+            )
+
+        if config.start >= config.stop:
+            raise ValueError(
+                f"Preview index error for {name}: start must be strictly smaller "
+                f"than stop, but start={config.start}, stop={config.stop}"
+            )
+
+    def _calculate_data_indices(self) -> List[int]:
+        if self._image_key is not None:
+            indices = np.where(self._image_key[:] == 0)[0].tolist()
+        else:
+            no_of_angles = self._dataset.shape[0]
+            indices = list(range(no_of_angles))
+
+        preview_data_indices = np.arange(
+            self.config.angles.start, self.config.angles.stop
+        )
+
+        intersection = np.intersect1d(indices, preview_data_indices)
+        if not np.array_equal(preview_data_indices, intersection):
+            self.config = PreviewConfig(
+                angles=PreviewDimConfig(
+                    start=intersection[0], stop=intersection[-1] + 1
+                ),
+                detector_y=self.config.detector_y,
+                detector_x=self.config.detector_x,
+            )
+
+        return intersection.tolist()
+
+    def _calculate_global_shape(self) -> Tuple[int, int, int]:
+        return (
+            len(self.data_indices),
+            self.config.detector_y.stop - self.config.detector_y.start,
+            self.config.detector_x.stop - self.config.detector_x.start,
+        )
+
+    @property
+    def data_indices(self) -> List[int]:
+        if self._data_indices is None:
+            self._data_indices = self._calculate_data_indices()
+        return self._data_indices
+
+    @property
+    def global_shape(self) -> Tuple[int, int, int]:
+        if self._global_shape is None:
+            self._global_shape = self._calculate_global_shape()
+        return self._global_shape
diff --git a/httomo/runner/auxiliary_data.py b/httomo/runner/auxiliary_data.py
new file mode 100644
index 000000000..36c01fdee
--- /dev/null
+++ b/httomo/runner/auxiliary_data.py
@@ -0,0 +1,92 @@
+from typing import Optional, Tuple, Union
+from typing_extensions import TypeAlias
+import numpy as np
+from httomo.utils import xp, gpu_enabled
+
+class AuxiliaryData:
+    """
+    Keeps the darks and flats and angles auxiliary data together, and separate from the dataset.
+    
+    This allows them to be updated on their own without being affected by chunks and blocks, etc.,
+    including the GPU/CPU transfers if needed.
+    """
+    
+    generic_array: TypeAlias = Union[xp.ndarray, np.ndarray]
+    
+    def __init__(self,  angles: np.ndarray, 
+                 darks: Optional[np.ndarray] = None, 
+                 flats: Optional[np.ndarray] = None):
+        self._darks: Optional[AuxiliaryData.generic_array] = darks
+        self._flats: Optional[AuxiliaryData.generic_array] = flats
+        self._angles: np.ndarray = angles
+
+    @property
+    def darks_dtype(self) -> Optional[np.dtype]:
+        return self._darks.dtype if self._darks is not None else None
+    
+    @property
+    def darks_shape(self) -> Tuple[int, int, int]:
+        if self._darks is None:
+            return (0, 0, 0)
+        assert len(self._darks.shape) == 3
+        return (self._darks.shape[0], self._darks.shape[1], self._darks.shape[2])
+    
+    @property
+    def flats_dtype(self) -> Optional[np.dtype]:
+        return self._flats.dtype if self._flats is not None else None
+    
+    @property
+    def flats_shape(self) -> Tuple[int, int, int]:
+        if self._flats is None:
+            return (0, 0, 0)
+        assert len(self._flats.shape) == 3
+        return (self._flats.shape[0], self._flats.shape[1], self._flats.shape[2])
+    
+    @property
+    def angles_dtype(self) -> np.dtype:
+        return self._angles.dtype
+    
+    @property
+    def angles_length(self) -> int:
+        return len(self._angles)
+    
+        
+    def get_darks(self, gpu=False) -> Optional[generic_array]:
+        return self._get_field("darks", gpu)
+    
+    def get_flats(self, gpu=False) -> Optional[generic_array]:
+        return self._get_field("flats", gpu)
+    
+    def get_angles(self) -> np.ndarray:
+        return self._angles
+    
+    def set_darks(self, darks: generic_array) -> None:
+        self._darks = darks
+        
+    def set_flats(self, flats: generic_array) -> None:
+        self._flats = flats
+        
+    def set_angles(self, angles: np.ndarray) -> None:
+        assert getattr(angles, "device", None) is None, "Angles must be a CPU array"
+        self._angles = angles
+        
+    def _get_field(self, field: str, gpu=False) -> generic_array:
+        assert not gpu or gpu_enabled, "GPU can only be used if the GPU is enabled"
+        
+        array = getattr(self, f"_{field}")
+        if array is None:
+            return array
+        
+        # Note: if already on CPU/GPU, no copy is taken
+        if gpu:
+            array = xp.asarray(array)
+        else:
+            array = xp.asnumpy(array)
+        
+        setattr(self, f"_{field}", array)
+        
+        return array
+    
+    def drop_darks_flats(self):
+        self._darks = None
+        self._flats = None
\ No newline at end of file
diff --git a/httomo/runner/backend_wrapper.py b/httomo/runner/backend_wrapper.py
deleted file mode 100644
index 0913bbfd8..000000000
--- a/httomo/runner/backend_wrapper.py
+++ /dev/null
@@ -1,731 +0,0 @@
-from mpi4py import MPI
-from httomo.methods import calculate_stats
-import httomo.globals
-from httomo.data import mpiutil
-from httomo.runner.dataset import DataSet, DataSetBlock
-from httomo.runner.methods_repository_interface import MethodRepository
-from httomo.runner.output_ref import OutputRef
-from httomo.utils import Colour, Pattern, gpu_enabled, log_once, log_rank, xp
-from httomo.runner.gpu_utils import gpumem_cleanup
-from httomo.data.hdf._utils.reslice import single_sino_reslice
-
-import numpy as np
-from mpi4py.MPI import Comm
-
-import logging
-import os
-from inspect import Parameter, signature
-from typing import Any, Callable, Dict, List, Optional, Tuple, Union
-
-log = logging.getLogger(__name__)
-
-
-class BackendWrapper:
-    """Defines a generic method backend wrapper in httomo which is used by task runner.
-
-    Method parameters (configuration parameters, usually set by the user) can be set either
-    using keyword arguments to the constructor, or by using conventional dictionary set/get methods
-    like::
-
-        wrapper["parameter"] = value
-    """
-
-    DictValues = Union[str, bool, int, float, os.PathLike, np.ndarray, xp.ndarray]
-    DictType = Dict[str, Union[DictValues, List[DictValues]]]
-
-    def __init__(
-        self,
-        method_repository: MethodRepository,
-        module_path: str,
-        method_name: str,
-        comm: Comm,
-        output_mapping: Dict[str, str] = {},
-        **kwargs,
-    ):
-        """Constructs a BackendWrapper for a method located in module_path with the name method_name.
-
-        Parameters
-        ----------
-
-        method_repository: MethodRepository
-            Repository that can be used to build queries about method attributes
-        module_path: str
-            Path to the module where the method is in python notation, e.g. "httomolibgpu.prep.normalize"
-        method_name: str
-            Name of the method (function within the given module)
-        comm: Comm
-            MPI communicator object
-        output_mapping: Dict[str, str]
-            Mapping of side-output names to new ones. Used for propagating side outputs by name.
-        **kwargs:
-            Additional keyword arguments will be set as configuration parameters for this
-            method. Note: The method must actually accept them as parameters.
-        """
-
-        self.comm = comm
-        self.module_path = module_path
-        self.method_name = method_name
-        from importlib import import_module
-
-        self.module = import_module(module_path)
-        self.method: Callable = getattr(self.module, method_name)
-        # get all the method parameter names, so we know which to set on calling it
-        sig = signature(self.method)
-        self.parameters = [
-            k for k, p in sig.parameters.items() if p.kind != Parameter.VAR_KEYWORD
-        ]
-        self._params_with_defaults = [
-            k for k, p in sig.parameters.items() if p.default != Parameter.empty
-        ]
-        self._has_kwargs = any(
-            p.kind == Parameter.VAR_KEYWORD for p in sig.parameters.values()
-        )
-        # check if the given kwargs are actually supported by the method
-        self._config_params = kwargs
-        self._output_mapping = output_mapping
-        self._check_config_params()
-
-        # assign method properties from the methods repository
-        self.query = method_repository.query(module_path, method_name)
-        self.pattern = self.query.get_pattern()
-        self.output_dims_change = self.query.get_output_dims_change()
-        self.implementation = self.query.get_implementation()
-        self.memory_gpu = self.query.get_memory_gpu_params()
-
-        if self.is_gpu and not gpu_enabled:
-            raise ValueError("GPU is not available, please use only CPU methods")
-
-        self._side_output: Dict[str, Any] = dict()
-
-        if gpu_enabled:
-            self.num_gpus = xp.cuda.runtime.getDeviceCount()
-            _id = httomo.globals.gpu_id
-            self.gpu_id = mpiutil.local_rank % self.num_gpus if _id == -1 else _id
-
-    @property
-    def cupyrun(self) -> bool:
-        return self.implementation == "gpu_cupy"
-
-    @property
-    def is_cpu(self) -> bool:
-        return self.implementation == "cpu"
-
-    @property
-    def is_gpu(self) -> bool:
-        return not self.is_cpu
-
-    @property
-    def package_name(self) -> str:
-        return self.module_path.split(".")[0]
-
-    def __getitem__(self, key: str) -> DictValues:
-        """Get a parameter for the method using dictionary notation (wrapper["param"])"""
-        return self._config_params[key]
-
-    def __setitem__(self, key: str, value: DictValues):
-        """Set a parameter for the method using dictionary notation (wrapper["param"] = 42)"""
-        self._config_params[key] = value
-        self._check_config_params()
-
-    @property
-    def config_params(self) -> Dict[str, Any]:
-        """Access a copy of the configuration parameters (cannot be modified directly)"""
-        return {**self._config_params}
-
-    def append_config_params(self, params: DictType):
-        """Append configuration parameters to the existing config_params"""
-        self._config_params |= params
-        self._check_config_params()
-
-    def _check_config_params(self):
-        if self._has_kwargs:
-            return
-        for k in self._config_params.keys():
-            if k not in self.parameters:
-                raise ValueError(
-                    f"{self.method_name}: Unsupported keyword argument given: {k}"
-                )
-
-    @property
-    def recon_algorithm(self) -> Optional[str]:
-        """Determine the recon algorithm used, if the method is reconstruction.
-        Otherwise return None."""
-        return None
-
-    def _build_kwargs(
-        self, dict_params: DictType, dataset: Optional[DataSet] = None
-    ) -> Dict[str, Any]:
-        # first parameter is always the data (if given)
-        ret: Dict[str, Any] = dict()
-        startidx = 0
-        if dataset is not None:
-            ret[self.parameters[startidx]] = dataset.data
-            startidx += 1
-        # other parameters are looked up by name
-        remaining_dict_params = self._resolve_output_refs(dict_params)
-        for p in self.parameters[startidx:]:
-            if dataset is not None and p in dir(dataset):
-                ret[p] = getattr(dataset, p)
-            elif p == "comm":
-                ret[p] = self.comm
-            elif p == "gpu_id":
-                assert gpu_enabled, "methods with gpu_id parameter require GPU support"
-                ret[p] = self.gpu_id
-            elif p in remaining_dict_params:
-                ret[p] = remaining_dict_params[p]
-            elif p in self._params_with_defaults:
-                pass
-            else:
-                raise ValueError(f"Cannot map method parameter {p} to a value")
-            remaining_dict_params.pop(p, None)
-
-        # if method supports kwargs, pass the rest as those
-        if len(remaining_dict_params) > 0 and self._has_kwargs:
-            ret |= remaining_dict_params
-        return ret
-
-    def _resolve_output_refs(self, dict_params: Dict[str, Any]) -> Dict[str, Any]:
-        """Check for OutputRef instances and resolve their value"""
-        ret: Dict[str, Any] = dict()
-        for k, v in dict_params.items():
-            if isinstance(v, OutputRef):
-                v = v.value
-            ret[k] = v
-        return ret
-
-    def execute(self, dataset: DataSet) -> DataSet:
-        """Execute functions for external packages.
-
-        Developer note: Derived classes may override this function or any of the methods
-        it uses to modify behaviour.
-
-        Parameters
-        ----------
-
-        dataset: DataSet
-            A numpy or cupy dataset, mutable (method might work in-place).
-
-        Returns
-        -------
-
-        DataSet
-            A CPU or GPU-based dataset object with the output
-        """
-
-        dataset = self._transfer_data(dataset)
-        dataset = self._preprocess_data(dataset)
-        args = self._build_kwargs(self._transform_params(self._config_params), dataset)
-        dataset = self._run_method(dataset, args)
-        dataset = self._postprocess_data(dataset)
-
-        return dataset
-
-    def _run_method(self, dataset: DataSet, args: Dict[str, Any]) -> DataSet:
-        """Runs the actual method - override if special handling is required
-        Or side outputs are produced."""
-        ret = self.method(**args)
-        dataset = self._process_return_type(ret, dataset)
-        return dataset
-
-    def _process_return_type(self, ret: Any, input_dataset: DataSet) -> DataSet:
-        """Checks return type of method call and assigns/creates return DataSet object.
-        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(
-                f"Invalid return type for method {self.method_name} (in module {self.module_path})"
-            )
-        if self.query.swap_dims_on_output():
-            ret = ret.swapaxes(0, 1)
-        input_dataset.data = ret
-        return input_dataset
-
-    def get_side_output(self) -> Dict[str, Any]:
-        """Override this method for functions that have a side output. The returned dictionary
-        will be merged with the dict_params parameter passed to execute for all methods that
-        follow in the pipeline"""
-        return {v: self._side_output[k] for k, v in self._output_mapping.items()}
-
-    def _transfer_data(self, dataset: DataSet):
-        if not self.cupyrun:
-            dataset.to_cpu()
-            return dataset
-
-        assert gpu_enabled, "GPU method used on a system without GPU support"
-
-        xp.cuda.Device(self.gpu_id).use()
-        gpulog_str = f"Using GPU {self.gpu_id} to transfer data of shape {xp.shape(dataset.data[0])}"
-        log_rank(gpulog_str, comm=self.comm)
-        gpumem_cleanup()
-        dataset.to_gpu()
-        return dataset
-
-    def _transform_params(self, dict_params: DictType) -> DictType:
-        """Hook for derived classes, for transforming the names of the possible method parameters
-        dictionary, for example to rename some of them or inspect them in some way"""
-        return dict_params
-
-    def _preprocess_data(self, dataset: DataSet) -> DataSet:
-        """Hook for derived classes to implement proprocessing steps, after the data has been
-        transferred and before the method is called"""
-        return dataset
-
-    def _postprocess_data(self, dataset: DataSet) -> DataSet:
-        """Hook for derived classes to implement postprocessing steps, after the method has been
-        called"""
-        return dataset
-
-    def calculate_output_dims(
-        self, non_slice_dims_shape: Tuple[int, int]
-    ) -> Tuple[int, int]:
-        """Calculate the dimensions of the output for this method"""
-        if self.output_dims_change:
-            return self.query.calculate_output_dims(
-                non_slice_dims_shape, **self.config_params
-            )
-
-        return non_slice_dims_shape
-
-    def calculate_max_slices(
-        self,
-        dataset: DataSet,
-        non_slice_dims_shape: Tuple[int, int],
-        available_memory: int,
-    ) -> Tuple[int, int]:
-        """If it runs on GPU, determine the maximum number of slices that can fit in the
-        available memory in bytes, and return a tuple of
-
-        (max_slices, available_memory)
-
-        The available memory may have been adjusted for the methods that follow, in case
-        something persists afterwards.
-        """
-        if self.is_cpu or not gpu_enabled:
-            return int(100e9), available_memory
-
-        # if we have no information, we assume in-place operation with no extra memory
-        if len(self.memory_gpu) == 0:
-            return (
-                available_memory
-                // (np.prod(non_slice_dims_shape) * dataset.data.itemsize),
-                available_memory,
-            )
-
-        # NOTE: This could go directly into the methodquery / method database,
-        # and here we just call calculated_memory_bytes
-        memory_bytes_method = 0
-        for field in self.memory_gpu:
-            subtract_bytes = 0
-            # loop over the dataset names given in the library file and extracting
-            # the corresponding dimensions from the available datasets
-            if field.dataset in ["flats", "darks"]:
-                assert field.multiplier is not None
-                # for normalisation module dealing with flats and darks separately
-                array: np.ndarray = getattr(dataset, field.dataset)
-                available_memory -= int(field.multiplier * array.nbytes)
-            else:
-                # deal with the rest of the data
-                if field.method == "direct":
-                    assert field.multiplier is not None
-                    # this calculation assumes a direct (simple) correspondence through multiplier
-                    memory_bytes_method += int(
-                        field.multiplier
-                        * np.prod(non_slice_dims_shape)
-                        * dataset.data.itemsize
-                    )
-                else:
-                    (
-                        memory_bytes_method,
-                        subtract_bytes,
-                    ) = self.query.calculate_memory_bytes(
-                        non_slice_dims_shape, dataset.data.dtype, **self.config_params
-                    )
-
-        if memory_bytes_method == 0:
-            return available_memory - subtract_bytes, available_memory
-
-        return (
-            available_memory - subtract_bytes
-        ) // memory_bytes_method, available_memory
-
-
-class ReconstructionWrapper(BackendWrapper):
-    """Wraps reconstruction functions, limiting the length of the angles array
-    before calling the method."""
-
-    def _preprocess_data(self, dataset: DataSet) -> DataSet:
-        # for 360 degrees data the angular dimension will be truncated while angles are not.
-        # Truncating angles if the angular dimension has got a different size
-        datashape0 = dataset.data.shape[0]
-        if datashape0 != len(dataset.angles_radians):
-            ds = dataset.base if isinstance(dataset, DataSetBlock) else dataset
-            ds.unlock()
-            ds.angles_radians = dataset.angles_radians[0:datashape0]
-            ds.lock()
-        self._input_shape = dataset.data.shape
-        return super()._preprocess_data(dataset)
-
-    def _build_kwargs(
-        self, dict_params: BackendWrapper.DictType, dataset: Optional[DataSet] = 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
-        assert (
-            len(self.parameters) >= 2
-        ), "recon methods always take data + angles as the first 2 parameters"
-        updated_params = {**dict_params, self.parameters[1]: dataset.angles_radians}
-        return super()._build_kwargs(updated_params, dataset)
-
-    @property
-    def recon_algorithm(self) -> Optional[str]:
-        assert "center" in self.parameters, (
-            "All recon methods should have a 'center' parameter, but it doesn't seem"
-            + f" to be the case for {self.module_path}.{self.method_name}"
-        )
-        return self._config_params.get("algorithm", None)
-
-class StatsCalcWrapper(BackendWrapper):
-    """This class calculates global statistics and deliver a side_output. 
-    It also forces to return the original dataset to be passed to the next method.
-    """
-    def __init__(
-        self,
-        method_repository: MethodRepository,
-        module_path: str,
-        method_name: str,
-        comm: Comm,
-        output_mapping: Dict[str, str] = {},
-        **kwargs,
-    ):
-        super().__init__(
-            method_repository, module_path, method_name, comm, output_mapping, **kwargs
-        )    
-    
-    def _run_method(self, dataset: DataSet, args: Dict[str, Any]) -> DataSet:
-        res = calculate_stats(dataset.data,
-                              comm=self.comm)        
-        stats_str = f"Global min {res[0]}, Global max {res[1]}, Global mean {res[2]}"
-        log_rank(stats_str, comm=self.comm)
-        return self._process_return_type(res, dataset)
-
-    def _process_return_type(self, ret: Any, input_dataset: DataSet) -> DataSet:
-        # getting (min, max, mean, total_elements) tuple  and assigning to side_output
-        self._side_output["glob_stats"] = ret
-        return input_dataset
-
-class RotationWrapper(BackendWrapper):
-    """Wraps rotation (centering) methods, which output the original dataset untouched,
-    but have a side output for the center of rotation data (handling both 180 and 360).
-
-    It wraps the actual algorithm to find the center and does more. In particular:
-    - takes a single sinogram from the full dataset (across all MPI processes)
-    - normalises it
-    - calls the center-finding algorithm on this normalised data slice
-    - outputs the center of rotation as a side output
-
-    For block-wise processing support, it accumulates the sinogram in-memory in the method
-    until the sinogram is complete for the current process. Then it uses MPI to add the data
-    from the other processes to it.
-    """
-
-    def __init__(
-        self,
-        method_repository: MethodRepository,
-        module_path: str,
-        method_name: str,
-        comm: Comm,
-        output_mapping: Dict[str, str] = {},
-        **kwargs,
-    ):
-        super().__init__(
-            method_repository, module_path, method_name, comm, output_mapping, **kwargs
-        )
-        if self.pattern not in [Pattern.sinogram, Pattern.all]:
-            raise NotImplementedError(
-                "Base method for rotation wrapper only supports sinogram or all"
-            )
-        # we must use projection in this wrapper now, to determine the full sino slice with MPI
-        # TODO: extend this to support Pattern.all
-        self.pattern = Pattern.projection
-        self.sino: Optional[np.ndarray] = None
-
-    def _build_kwargs(
-        self, dict_params: BackendWrapper.DictType, dataset: Optional[DataSet] = None
-    ) -> Dict[str, Any]:
-        assert (
-            dataset is not None
-        ), "Rotation wrapper cannot work without a dataset input"
-        # if the function needs "ind" argument, and we either don't give it or have it as "mid",
-        # we set it to the mid point of the data dim 1
-        updated_params = dict_params
-        if not "ind" in dict_params or (
-            dict_params["ind"] == "mid" or dict_params["ind"] is None
-        ):
-            updated_params = {**dict_params, "ind": (dataset.global_shape[1] - 1) // 2}
-        return super()._build_kwargs(updated_params, dataset)
-
-    def _gather_sino_slice(self, global_shape: Tuple[int, int, int]):
-        assert self.sino is not None
-        if self.comm.size == 1:
-            return self.sino
-
-        # now aggregate with MPI
-        sendbuf = self.sino.reshape(self.sino.size)
-        sizes_rec = self.comm.gather(sendbuf.size)
-        if self.comm.rank == 0:
-            recvbuf = np.empty(global_shape[0] * global_shape[2], dtype=np.float32)
-            self.comm.Gatherv(
-                (sendbuf, self.sino.size, MPI.FLOAT),
-                (recvbuf, sizes_rec, MPI.FLOAT),
-                root=0,
-            )
-            return recvbuf.reshape((global_shape[0], global_shape[2]))
-        else:
-            self.comm.Gatherv(
-                (sendbuf, self.sino.size, MPI.FLOAT),
-                (None, sizes_rec, MPI.FLOAT),
-                root=0,
-            )
-            return None
-
-    def _run_method(self, dataset: DataSet, args: Dict[str, Any]) -> DataSet:
-        assert "ind" in args
-        slice_for_cor = args["ind"]
-        # append to internal sinogram, until we have the last block
-        if self.sino is None:
-            self.sino = np.empty(
-                (dataset.chunk_shape[0], dataset.chunk_shape[2]), dtype=np.float32
-            )
-        data = dataset.data[:, slice_for_cor, :]
-        if dataset.is_gpu:
-            data = data.get()
-        self.sino[
-            dataset.chunk_index[0] : dataset.chunk_index[0] + dataset.shape[0], :
-        ] = data
-
-        if not dataset.is_last_in_chunk:  # exit if we didn't process all blocks yet
-            return dataset
-
-        sino_slice = self._gather_sino_slice(dataset.global_shape)
-
-        # now calculate the center of rotation on rank 0 and broadcast
-        res: Optional[Union[tuple, float, np.float32]] = None
-        if self.comm.rank == 0:
-            sino_slice = self.normalize_sino(
-                sino_slice,
-                dataset.flats[:, slice_for_cor, :],
-                dataset.darks[:, slice_for_cor, :],
-            )
-            if self.cupyrun:
-                sino_slice = xp.asarray(sino_slice)
-            args["ind"] = 0
-            args[self.parameters[0]] = sino_slice
-            res = self.method(**args)
-        if self.comm.size > 1:
-            res = self.comm.bcast(res, root=0)
-        
-        cor_str = f"The center of rotation for sinogram is {res}"
-        log_rank(cor_str, comm=self.comm)
-        return self._process_return_type(res, dataset)
-
-    @classmethod
-    def normalize_sino(
-        cls, sino: np.ndarray, flats: Optional[np.ndarray], darks: Optional[np.ndarray]
-    ) -> np.ndarray:
-        flats1d = 1.0 if flats is None else flats.mean(0, dtype=np.float32)
-        darks1d = 0.0 if darks is None else darks.mean(0, dtype=np.float32)
-        denom = flats1d - darks1d
-        sino = sino.astype(np.float32)
-        if np.shape(denom) == tuple():
-            sino -= darks1d  # denominator is always 1
-        elif getattr(denom, "device", None) is not None:
-            # GPU
-            denom[xp.where(denom == 0.0)] = 1.0
-            sino = xp.asarray(sino)
-            sino -= darks1d / denom
-        else:
-            # CPU
-            denom[np.where(denom == 0.0)] = 1.0
-            sino -= darks1d / denom
-        return sino[:, np.newaxis, :]
-
-    def _process_return_type(self, ret: Any, input_dataset: DataSet) -> DataSet:
-        if type(ret) == tuple:
-            # cor, overlap, side, overlap_position - from find_center_360
-            self._side_output["cor"] = float(ret[0])
-            self._side_output["overlap"] = float(ret[1])
-            self._side_output["side"] = int(ret[2]) if ret[2] is not None else None
-            self._side_output["overlap_position"] = float(ret[3])  # float
-        else:
-            self._side_output["cor"] = float(ret)
-        return input_dataset
-
-
-class DezingingWrapper(BackendWrapper):
-    """Wraps the remove_outlier3d method, to clean/dezing the data.
-    Note that this method is applied to all elements of the dataset, i.e.
-    data, darks, and flats.
-    """
-
-    def __init__(
-        self,
-        method_repository: MethodRepository,
-        module_path: str,
-        method_name: str,
-        comm: Comm,
-        output_mapping: Dict[str, str] = {},
-        **kwargs,
-    ):
-        super().__init__(
-            method_repository, module_path, method_name, comm, output_mapping, **kwargs
-        )
-        assert (
-            method_name == "remove_outlier3d"
-        ), "Only remove_outlier3d is supported at the moment"
-        self._flats_darks_processed = False
-
-    def execute(self, dataset: Union[DataSetBlock, DataSet]) -> DataSet:
-        # check if data needs to be transfered host <-> device
-        dataset = self._transfer_data(dataset)
-
-        dataset.data = self.method(dataset.data, **self._config_params)
-        if not self._flats_darks_processed:
-            darks = self.method(dataset.darks, **self._config_params)
-            flats = self.method(dataset.flats, **self._config_params)
-            ds = dataset.base if isinstance(dataset, DataSetBlock) else dataset
-            ds.unlock()
-            ds.darks = darks
-            ds.flats = flats
-            ds.lock()
-            self._flats_darks_processed = True
-
-        return dataset
-
-
-class ImagesWrapper(BackendWrapper):
-    """Wraps image writer methods, which accept numpy (CPU) arrays as input,
-    but don't actually modify the dataset. They write the information to files"""
-
-    def __init__(
-        self,
-        method_repository: MethodRepository,
-        module_path: str,
-        method_name: str,
-        comm: Comm,
-        out_dir: os.PathLike,
-        output_mapping: Dict[str, str] = {},
-        **kwargs,
-    ):
-        super().__init__(
-            method_repository, module_path, method_name, comm, output_mapping, **kwargs
-        )
-        self["out_dir"] = out_dir
-        self["comm_rank"] = comm.rank
-
-    # 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,
-        dataset: DataSet,
-    ) -> DataSet:
-        args = self._build_kwargs(self._transform_params(self._config_params), dataset)
-        if dataset.is_gpu:
-            # give method a CPU copy of the data
-            args[self.parameters[0]] = xp.asnumpy(dataset.data)
-
-        self.method(**args)
-
-        return dataset
-
-
-def make_backend_wrapper(
-    method_repository: MethodRepository,
-    module_path: str,
-    method_name: str,
-    comm: Comm,
-    output_mapping: Dict[str, str] = {},
-    **kwargs,
-) -> BackendWrapper:
-    """Factory function to generate the appropriate wrapper based on the module
-    path and method name. Clients do not need to be concerned about which particular
-    derived class is returned.
-
-    Parameters
-    ----------
-
-    method_repository: MethodRepository
-        Repository of methods that we can use the query properties
-    module_path: str
-        Path to the module where the method is in python notation, e.g. "httomolibgpu.prep.normalize"
-    method_name: str
-        Name of the method (function within the given module)
-    comm: Comm
-        MPI communicator object
-    output_mapping: Dict[str, str]
-        A dictionary mapping output names to translated ones. The side outputs will be renamed
-        as specified, if the parameter is given. If not, no side outputs will be passed on.
-    kwargs:
-        Arbitrary keyword arguments that get passed to the method as parameters.
-
-    Returns
-    -------
-
-    BackendWrapper
-        An instance of a wrapper class
-    """
-
-    if module_path.endswith(".algorithm"):
-        return ReconstructionWrapper(
-            method_repository=method_repository,
-            module_path=module_path,
-            method_name=method_name,
-            comm=comm,
-            output_mapping=output_mapping,
-            **kwargs,
-        )
-    if module_path.endswith(".rotation"):
-        return RotationWrapper(
-            method_repository=method_repository,
-            module_path=module_path,
-            method_name=method_name,
-            comm=comm,
-            output_mapping=output_mapping,
-            **kwargs,
-        )
-    if method_name == "remove_outlier3d":
-        return DezingingWrapper(
-            method_repository=method_repository,
-            module_path=module_path,
-            method_name=method_name,
-            comm=comm,
-            output_mapping=output_mapping,
-            **kwargs,
-        )
-    if method_name == "calculate_stats":
-        return StatsCalcWrapper(
-            method_repository=method_repository,
-            module_path=module_path,
-            method_name=method_name,
-            comm=comm,
-            output_mapping=output_mapping,
-            **kwargs,
-        )    
-    if module_path.endswith(".images"):
-        return ImagesWrapper(
-            method_repository=method_repository,
-            module_path=module_path,
-            method_name=method_name,
-            comm=comm,
-            output_mapping=output_mapping,
-            out_dir=httomo.globals.run_out_dir,
-            **kwargs,
-        )
-    return BackendWrapper(
-        method_repository=method_repository,
-        module_path=module_path,
-        method_name=method_name,
-        comm=comm,
-        output_mapping=output_mapping,
-        **kwargs,
-    )
diff --git a/httomo/runner/block_split.py b/httomo/runner/block_split.py
index 010c506dc..e9bdc7d38 100644
--- a/httomo/runner/block_split.py
+++ b/httomo/runner/block_split.py
@@ -1,35 +1,33 @@
 import math
 from typing import Iterator
-import numpy as np
-from httomo.runner.dataset import DataSet
-from httomo.utils import Pattern, _get_slicing_dim
+from httomo.runner.dataset import DataSetBlock
+from httomo.runner.dataset_store_interfaces import DataSetSource
 import logging
 
 log = logging.getLogger(__name__)
 
 class BlockSplitter:
-    """Can split a full DataSet object into blocks according to the given max slices
+    """Produces blocks from a DataSetSource according to the given max slices
     per block. It provides an iterator interface, so that it can be used as::
 
-         splitter = BlockSplitter(dataset, pattern, max_slices)
+         splitter = BlockSplitter(source, max_slices)
          for block in splitter:
              process_block(block)
 
     Where a block is a DataSet instance.
 
     Note that a slice of the data is returned and no copy is made.
-    Also note that the dataset is copied to CPU if not there already.
     """
 
-    def __init__(self, full_data: DataSet, pattern: Pattern, max_slices: int):
-        self._full_data = full_data
-        self._full_data.to_cpu()
-        self._pattern = pattern
-        self._slicing_dim = _get_slicing_dim(pattern) - 1
-        self._max_slices = int(min(max_slices, full_data.data.shape[self._slicing_dim]))
-        self._num_blocks = math.ceil(
-            full_data.data.shape[self._slicing_dim] / self._max_slices
-        )
+    def __init__(self, source: DataSetSource, max_slices: int):
+        self._source = source
+        self._chunk_size = source.chunk_shape[source.slicing_dim]
+        self._max_slices = int(min(max_slices, self._chunk_size))
+        self._num_blocks = math.ceil(self._chunk_size / self._max_slices)
+        assert self._source.slicing_dim in [
+            0,
+            1,
+        ], "Only supporting slicing in projection and sinogram dimension"
 
     @property
     def slices_per_block(self) -> int:
@@ -38,24 +36,23 @@ def slices_per_block(self) -> int:
     def __len__(self):
         return self._num_blocks
 
-    def __getitem__(self, idx: int) -> DataSet:
-        assert self._slicing_dim in [
-            0,
-            1,
-        ], "Only supporting slicing in projection and sinogram dimension"
-
-        # dim, startidx, length -> DataSet
-        return self._full_data.make_block(
-            self._slicing_dim, idx * self.slices_per_block, self.slices_per_block
-        )
+    def __getitem__(self, idx: int) -> DataSetBlock:
+        start = idx * self.slices_per_block
+        if start >= self._chunk_size:
+            raise IndexError("Index out of bounds")
+        len = min(self.slices_per_block, self._chunk_size - start)
+        return self._source.read_block(start, len)
 
-    def __iter__(self) -> Iterator[DataSet]:
+    def __iter__(self) -> Iterator[DataSetBlock]:
         class BlockIterator:
             def __init__(self, splitter):
                 self.splitter = splitter
                 self._current = 0
+                
+            def __iter__(self) -> 'BlockIterator':
+                return self  # pragma: no cover
 
-            def __next__(self) -> DataSet:
+            def __next__(self) -> DataSetBlock:
                 if self._current >= len(self.splitter):
                     raise StopIteration
                 v = self.splitter[self._current]
@@ -64,64 +61,3 @@ def __next__(self) -> DataSet:
 
         return BlockIterator(self)
 
-
-class BlockAggregator:
-    """Aggregates multiple blocks back into the full dataset (after blockwise processing).
-
-    Note that the dataset is copied to CPU if not there already
-    """
-
-    def __init__(self, full_dataset: DataSet, pattern: Pattern):
-        self._dataset = full_dataset
-        self._dataset.to_cpu()
-        self._current_idx = 0
-        self._slicing_dim = _get_slicing_dim(pattern) - 1
-        self._full_size = full_dataset.data.shape[self._slicing_dim]
-        log.debug(f"created aggregator: dim={self._slicing_dim}, full_size={self._full_size}")
-
-    def append(self, dataset: DataSet):
-        append_size = dataset.data.shape[self._slicing_dim]
-        log.debug(f"appending {append_size}, current={self._current_idx}, full={self._full_size} (dim={self._slicing_dim})")
-        if append_size + self._current_idx > self._full_size:
-            raise ValueError(
-                f"Cannot append another {append_size} slices - only {self._full_size-self._current_idx} slices left"
-            )
-        self._increase_dims_if_needed(dataset.data.shape, dataset.data.dtype)
-        to_idx = [slice(None), slice(None), slice(None)]
-        to_idx[self._slicing_dim] = slice(
-            self._current_idx, self._current_idx + append_size
-        )
-        dataset.to_cpu()
-        self._dataset.data[tuple(to_idx)] = dataset.data
-        self._current_idx += append_size
-
-    def _increase_dims_if_needed(self, append_data_shape: tuple, append_dtype: np.dtype):
-        other_dims = np.delete(append_data_shape, self._slicing_dim)
-        other_dims_full = np.delete(self._dataset.data.shape, self._slicing_dim)
-        if any(other != this for other, this in zip(other_dims_full, other_dims)) or append_dtype != self._dataset.data.dtype:
-            if self._current_idx != 0:
-                raise ValueError(
-                    f"Received block of shape {append_data_shape} and type {append_dtype},"
-                    + f" while full data is of the different shape {self._dataset.data.shape} or type {self._dataset.data.dtype}"
-                    + " and already has data blocks"
-                )
-            new_full_shape = np.insert(other_dims, self._slicing_dim, self._full_size)
-            new_global_shape = np.insert(other_dims, self._slicing_dim, self._dataset.global_shape[self._slicing_dim])
-            # need a new object, otherwise we might modify the source in-place
-            self._dataset = DataSet(
-                data=np.empty(new_full_shape, append_dtype),
-                darks=self._dataset.darks,
-                flats=self._dataset.flats,
-                angles=self._dataset.angles,
-                global_index=self._dataset.global_index,
-                global_shape=tuple(new_global_shape)
-            )
-
-    @property
-    def full_dataset(self) -> DataSet:
-        if self._current_idx != self._full_size:
-            raise ValueError(
-                f"Aggregation not finished - {self._current_idx} slices of {self._full_size} have been aggregated"
-            )
-
-        return self._dataset
diff --git a/httomo/runner/cpu_chunking_design.md b/httomo/runner/cpu_chunking_design.md
new file mode 100644
index 000000000..69d789cad
--- /dev/null
+++ b/httomo/runner/cpu_chunking_design.md
@@ -0,0 +1,69 @@
+## CPU Chunking
+
+* *important:* we actually don't split on platform changes anymore - transfers are happening automatically in the wrappers
+* try to avoid files for slices/chunks as much as possible:
+  * adapt the `BlockSplitter` and `BlockAggregator` to be able to go from/to file
+  * should read/write chunks from memory if they can at all 
+  * CPU memory estimation is highly variable - best is to try, and catch allocation errors - then react by swapping out to file (trial and error)
+  * If allocation fails or it doesn't fit in memory anymore, the aggregator starts using files -> then file-based reslice is needed
+  
+* **GOAL:** use the same chunking loop as we have for GPUs also for CPUs
+
+### Special Cases / Considerations
+
+#### Loader
+
+* Make sure that we can ask the loader for a block only, not the whole chunk
+  (iteratively load)
+* So we must include the loader in the blockwise loop
+* "It behaves like the `BlockSplitter` when there's not enough memory for a chunk"
+
+#### Reslice
+
+* really needs be between sections
+* needs to ask the block aggregator if it had to use files (didn't fit in memory)
+* if yes, use file-based reslice
+* if no, use in-memory reslice
+* Idea:
+  * what if the file-based `BlockAggregator`/`BlockSplitter` supported writing in one direction and reading in another?
+  * this makes it not a "thing you insert between section" -> it's part of the sectioning
+
+#### Global Stats
+
+* methods needing them are the first in a section
+* needs to be able to work cumulatively, block by block -> per-chunk stats
+* then use MPI to get get global stats
+* No need to start / stop a new section because of this
+* could this be a block-wise method wrapper (that keeps state)?
+  * accumulate the stats in the wrapper (state)
+  * on the last block, it produces side outputs with the stats values
+  * other methods can reference these side outputs...
+
+#### Centering Meta-Wrapper
+
+* It's method wrapper working block-by-block, producing a side output at the end of a chunk
+* just like global stats
+
+#### Save Intermediate Files
+
+* Causes a split and starts a new section at the moment
+* Must work block-by-block - cannot assume we can fit a whole chunk at once potentially
+* Iterative appending to the files is necessary
+* Options:
+  * "It behaves like the `BlockAggregator` when there's not enough memory for a chunk"
+  * Or: Implement like a method wrapper:
+     *  --> No need to start / stop a new section because of this
+
+#### Save to Images
+
+* must work block-by block
+* it's already a method wrapper - would work pretty much like saving intermediate files with a method wrapper
+
+
+## Checkpointing (for future)
+
+* Can be implemented by artifically inserting new section splits, and explicitly forcing the splitter/aggregator to go through files
+* If block-wise state should be saved, need to be careful with methods that keep state:
+  * perhaps have a method on the wrappers `get_state` and `set_state` that is rather abstract (any kind of state can be retrieved and set)
+  * on a checkpoint (e.g. every 10sec), task runner would ask all methods in a section for their internal state and store that to file as well
+  * When recovering, the last state at the checkpoint can be loaded and restored
\ No newline at end of file
diff --git a/httomo/runner/dataset.py b/httomo/runner/dataset.py
index 1c6af6271..a62ca6c46 100644
--- a/httomo/runner/dataset.py
+++ b/httomo/runner/dataset.py
@@ -1,147 +1,146 @@
-from typing import Optional, Tuple, TypeAlias, Union
+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 httomo.utils import make_3d_shape_from_shape
+from httomo.utils import make_3d_shape_from_array
 
-class DataSet:
-    """Holds the dataset the methods work on, handling both CPU or GPU.
-    It handles the transfers if needed internally, given the currently-active
-    GPU device in the CuPy context.
 
-    Flats, darks, and angles are assumed to be read-only - separate copies on
-    CPU and GPU are maintained as needed. Depending on where data itself is,
-    the flats/darks/angles will be returned on the same device (and cached).
-
-    Example::
+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.
 
-       dataset = DataSet(data, angles, flats, darks)
-       dataset.data        # access data
-       dataset.to_gpu()    # transfer
-       dataset.data        # now returns a GPU-based array
-       assert dataset.is_gpu is True
+    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.
     """
-
-    generic_array: TypeAlias = Union[xp.ndarray, np.ndarray]
-
-    # TODO: Think about whether detector_x, detector_y, angles_total should be here?
+    
+    generic_array: TypeAlias = Union[np.ndarray, xp.ndarray]
 
     def __init__(
         self,
-        data: generic_array,
-        angles: np.ndarray,
-        flats: np.ndarray,
-        darks: np.ndarray,
-        global_shape: Tuple[int, int, int] = None,
-        global_index: Tuple[int, int, int] = (0, 0, 0),
+        data: np.ndarray,
+        aux_data: AuxiliaryData,
+        slicing_dim: int = 0,
+        block_start: int = 0,
+        chunk_start: int = 0,
+        global_shape: Optional[Tuple[int, int, int]] = None,
+        chunk_shape: Optional[Tuple[int, int, int]] = None,
     ):
-        self._angles = angles
-        self._flats = flats
-        self._darks = darks
         self._data = data
+        self._aux_data = aux_data
+        self._slicing_dim = slicing_dim
+        self._block_start = block_start
+        self._chunk_start = chunk_start
 
-        self._global_shape = global_shape
-        if self._global_shape is None:
-            self._global_shape = data.shape
-        elif any(i < d for i, d in zip(self._global_shape, data.shape)):
-            raise ValueError(
-                f"A global shape of {self._global_shape} is incompatible with a local shape {data.shape}"
-            )
-        self._global_index = global_index
-
-        self.lock()
-        if gpu_enabled:
-            import cupy as cp
-
-            # cached GPU data - transferred lazily
-            self._flats_gpu: Optional[cp.ndarray] = None
-            self._darks_gpu: Optional[cp.ndarray] = None
-            # keep track if fields have been reset on GPU, to ensure
-            # transferring back to CPU if needed
-            self._flats_dirty: bool = False
-            self._darks_dirty: bool = False
-
-    @property
-    def global_shape(self) -> Tuple[int, int, int]:
-        """Overall shape of the data, regardless of chunking and blocking"""
-        return self._global_shape
-
+        if global_shape is None:
+            self._global_shape = make_3d_shape_from_array(data)
+        else:
+            self._global_shape = global_shape
+            
+        if chunk_shape is None:
+            self._chunk_shape = make_3d_shape_from_array(data)
+        else:
+            self._chunk_shape = chunk_shape
+
+        chunk_index = [0, 0, 0]
+        chunk_index[slicing_dim] += block_start
+        self._chunk_index = make_3d_shape_from_shape(chunk_index)
+        global_index = [0, 0, 0]
+        global_index[slicing_dim] += chunk_start + block_start
+        self._global_index = make_3d_shape_from_shape(global_index)
+
+        self._check_inconsistencies()
+        
+    def _check_inconsistencies(self):
+        if self.chunk_index[self.slicing_dim] < 0:
+            raise ValueError("block start index must be >= 0")
+        if self.chunk_index[self.slicing_dim] + self.shape[self.slicing_dim] > self.chunk_shape[self.slicing_dim]:
+            raise ValueError("block spans beyond the chunk's boundaries")
+        if self.global_index[self.slicing_dim] < 0:
+            raise ValueError("chunk start index must be >= 0")
+        if self.global_index[self.slicing_dim] + self.shape[self.slicing_dim] > self.global_shape[self.slicing_dim]:
+            raise ValueError("chunk spans beyond the global data boundaries")
+        if any(self.chunk_shape[i] > self.global_shape[i] for i in range(3)):    
+            raise ValueError("chunk shape is larger than the global shape")
+        if any(self.shape[i] > self.chunk_shape[i] for i in range(3)):
+            raise ValueError("block shape is larger than the chunk shape")
+        if any(self.shape[i] != self.global_shape[i] for i in range(3) if i != self.slicing_dim):
+            raise ValueError("block shape inconsistent with non-slicing dims of global shape")
+        
+        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 this part of the dataset"""
-        return self._data.shape
-
-    @property
-    def global_index(self) -> Tuple[int, int, int]:
-        """The starting indices in all 3 dimensions of this data block within the
-        global data."""
-        return self._global_index
+        """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 dataset within the chunk handled by the current process"""
-        return (0, 0, 0)
+        """The index of this block within the chunk handled by the current process"""
+        return self._chunk_index
 
     @property
     def chunk_shape(self) -> Tuple[int, int, int]:
         """Shape of the full chunk handled by the current process"""
-        return self.shape
-
+        return self._chunk_shape
+    
     @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"""
-        return True
+    def global_index(self) -> Tuple[int, int, int]:
+        """The index of this block within the global data across all processes"""
+        return self._global_index
 
+    @property
+    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._get_value("angles")
-
+        return self._aux_data.get_angles()
+    
     @angles.setter
-    def angles(self, new_data: np.ndarray):
-        self._set_value("angles", new_data)
-
+    def angles(self, new_angles: np.ndarray):
+        self._aux_data.set_angles(new_angles)
+    
     @property
     def angles_radians(self) -> np.ndarray:
-        """Alias for angles"""
         return self.angles
-
+    
     @angles_radians.setter
-    def angles_radians(self, new_data: np.ndarray):
-        """Alias setter for angles"""
-        self.angles = new_data
-
-    @property
-    def darks(self) -> generic_array:
-        return self._get_value("darks")
-
-    @darks.setter
-    def darks(self, new_data: generic_array):
-        self._set_value("darks", new_data)
-
-    @property
-    def dark(self) -> generic_array:
-        """Alias for darks"""
-        return self.darks
-
-    @dark.setter
-    def dark(self, new_data: generic_array):
-        self.darks = new_data
+    def angles_radians(self, new_angles: np.ndarray):
+        self.angles = new_angles
 
     @property
-    def flats(self) -> generic_array:
-        return self._get_value("flats")
-
-    @flats.setter
-    def flats(self, new_data: generic_array):
-        self._set_value("flats", new_data)
+    def is_last_in_chunk(self) -> bool:
+        """Check if the current dataset is the final one for the chunk handled by the current process"""
+        return (
+            self.chunk_index[self._slicing_dim] + self.shape[self._slicing_dim]
+            == self.chunk_shape[self._slicing_dim]
+        )
 
     @property
-    def flat(self) -> generic_array:
-        """Alias for flats"""
-        return self.flats
-
-    @flat.setter
-    def flat(self, new_data: generic_array):
-        self.flats = new_data
+    def slicing_dim(self) -> int:
+        return self._slicing_dim
+    
+    def _empty_aux_array(self):
+        empty_shape = list(self._data.shape)
+        empty_shape[self.slicing_dim] = 0
+        return np.empty_like(self._data, shape=empty_shape)
 
     @property
     def data(self) -> generic_array:
@@ -149,192 +148,70 @@ def data(self) -> generic_array:
 
     @data.setter
     def data(self, new_data: generic_array):
+        global_shape = list(self._global_shape)
+        chunk_shape = list(self._chunk_shape)
+        for i in range(3):
+            if i != self.slicing_dim:
+                global_shape[i] = new_data.shape[i]
+                chunk_shape[i] = new_data.shape[i]
+            elif self._data.shape[i] != new_data.shape[i]:
+                raise ValueError("shape mismatch in slicing dimension")
+                
         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 is_gpu(self) -> bool:
-        """Check if arrays are currently residing on GPU"""
-        return getattr(self._data, "device", None) is not None
+    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 is_cpu(self) -> bool:
-        """Check if arrays are currently residing on CPU"""
-        return not self.is_gpu
-
-    def lock(self):
-        """Makes angles, darks and flats read-only, to avoid coding errors.
-        Note: this is the default in the constructor - only the the data array is writable
-        Also note that this is only supported for the numpy arrays - gpu arrays can't be locked
-        """
-        self._angles.setflags(write=False)
-        self._darks.setflags(write=False)
-        self._flats.setflags(write=False)
-        self._is_locked = True
-
-    def unlock(self):
-        """Unlock the read-only flag for the darks, angles, and flats members,
-        allowing to overwrite them with the setter or modify their values"""
-        self._angles.setflags(write=True)
-        self._darks.setflags(write=True)
-        self._flats.setflags(write=True)
-        self._is_locked = False
+    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 is_locked(self) -> bool:
-        return self._is_locked
+    def flat(self) -> generic_array:
+        return self.flats
+    
+    @flat.setter
+    def flat(self, flats: generic_array):
+        self.flats = flats
 
     def to_gpu(self):
-        """Transfer dataset to GPU if not already."""
         if not gpu_enabled:
-            raise ValueError("cannot transfer to GPU if not enabled")
-        self._data = xp.asarray(self._data)
+            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):
-        """Transfter dataset to CPU (if not already)"""
-        if not self.is_gpu:
+        if not gpu_enabled:
             return
-        self._data = xp.asnumpy(self._data)
-
-    def make_block(self, dim: int, start: int, length: int):
-        """Create a block from this dataset, which slices in dimension `dim`
-        starting at index `start`, and taking `length` elements.
-
-        The returned block is a `DataSet` object itself, but it references the
-        original one for the darks/flats/angles arrays and re-use the GPU-cached
-        version of those if needed."""
-        return DataSetBlock(self, dim, start, length)
-
-    @property
-    def is_block(self) -> bool:
-        """Check if this DataSet is a block (output of `make_block`)"""
-        return False
-
+        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"]
-
-    ###### internal helpers ######
-
-    def _get_value(
-        self, field: str, data_is_gpu: Optional[bool] = None
-    ) -> generic_array:
-        """Helper function to get a field from this object.
-        It uses getattr/setattr a lot, to allow for re-use from all the getters.
-
-        `data_is_gpu` can be used to tell this method to assume the data array
-        is on GPU or not - it will be used instead of self.is_gpu if given"""
-
-        # angles always stay on CPU
-        if field == "angles":
-            return getattr(self, f"_{field}")
-
-        is_gpu = data_is_gpu if data_is_gpu is not None else self.is_gpu
-        if is_gpu:
-            setattr(
-                self,
-                f"_{field}_gpu",
-                self._transfer_if_needed(
-                    getattr(self, f"_{field}"), getattr(self, f"_{field}_gpu")
-                ),
-            )
-            return getattr(self, f"_{field}_gpu")
-        if gpu_enabled and getattr(self, f"_{field}_dirty"):
-            setattr(self, f"_{field}", xp.asnumpy(getattr(self, f"_{field}_gpu")))
-            setattr(self, f"_{field}_dirty", False)
-        return getattr(self, f"_{field}")
-
-    def _set_value(self, field: str, new_data: generic_array):
-        """Sets a value of a field in this object, only if unlocked.
-        It is a helper used in the setters for darks, flats, angles"""
-        if self.is_locked:
-            raise ValueError(f"attempt to reset {field} in a locked dataset")
-        if not gpu_enabled or field == "angles":
-            assert (
-                getattr(new_data, "device", None) is None
-            ), f"GPU array for CPU-only field {field}"
-            setattr(self, f"_{field}", new_data)
-            return
-        if getattr(new_data, "device", None) is not None:
-            # got GPU data - mark CPU dirty
-            setattr(self, f"_{field}_gpu", new_data)
-            setattr(self, f"_{field}_dirty", True)
-        else:
-            # got CPU data - make sure we remove cached GPU array
-            setattr(self, f"_{field}_gpu", None)
-            setattr(self, f"_{field}", new_data)
-
-    def _transfer_if_needed(self, cpuarray: np.ndarray, gpuarray: Optional[xp.ndarray]):
-        """Internal helper to transfer flats/darks/angles lazily"""
-        if gpuarray is None:
-            gpuarray = xp.asarray(cpuarray)
-        assert (
-            gpuarray.device.id == xp.cuda.Device().id
-        ), f"GPU array is on a different GPU (expected: {xp.cuda.Device().id}, actual: {gpuarray.device.id})"
-        return gpuarray
-
-
-class DataSetBlock(DataSet):
-    """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.
-    """
-
-    def __init__(self, base: DataSet, dim: int, start: int, length: int):
-        idx_expr = [slice(None), slice(None), slice(None)]
-        idx_expr[dim] = slice(start, start + length)
-        global_index = list(base.global_index)
-        global_index[dim] += start
-        # we pass an empty size-0 array to base class, as we're not going to use these
-        # fields anyway here (we access the originals via self._base)
-        super().__init__(
-            data=base.data[tuple(idx_expr)],
-            flats=np.empty((0,)),
-            darks=np.empty((0,)),
-            angles=np.empty((0,)),
-            global_shape=base.global_shape,
-            global_index=tuple(global_index),
-        )
-        self._base = base
-        idx = [0, 0, 0]
-        idx[dim] = start
-        self._chunk_index = tuple(idx)
-        self._dim = dim
-
-    @property
-    def is_block(self) -> bool:
-        return True
-
-    @property
-    def chunk_index(self) -> Tuple[int, int, int]:
-        """The index of this dataset within the chunk handled by the current process"""
-        return self._chunk_index
-
-    @property
-    def chunk_shape(self) -> Tuple[int, int, int]:
-        """Shape of the full chunk handled by the current process"""
-        return self._base.shape
-
-    @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"""
-        return (
-            self.chunk_index[self._dim] + self.shape[self._dim]
-            == self.chunk_shape[self._dim]
-        )
-
-    @property
-    def base(self) -> DataSet:
-        """Get the original (unblocked) dataset"""
-        return self._base
-
-    def _set_value(self, field: str, new_data: DataSet.generic_array):
-        raise ValueError(f"Cannot update field {field} in a block/slice dataset")
-
-    def _get_value(self, field: str) -> DataSet.generic_array:
-        return self._base._get_value(field, self.is_gpu)
-
-    def make_block(self, dim: int, start: int, length: int):
-        raise ValueError("Cannot slice a dataset that is already a slice")
diff --git a/httomo/runner/dataset_store_interfaces.py b/httomo/runner/dataset_store_interfaces.py
new file mode 100644
index 000000000..1a5d583c1
--- /dev/null
+++ b/httomo/runner/dataset_store_interfaces.py
@@ -0,0 +1,176 @@
+import abc
+from typing import Literal, Optional, Protocol, Tuple
+import numpy as np
+
+from httomo.runner.dataset import DataSetBlock
+from httomo.runner.auxiliary_data import AuxiliaryData
+
+# Interfaces for an MPI-aware store for full datasets, where each process handles a *chunk*, and
+# the data can be read or written in *blocks*, sliced in the slicing dimension.
+#
+# The source is used as an interface for readers, and sink for writers.
+# Note that any implementation might implement both interfaces.
+#
+# Possible implementation (other approaches are thinkable):
+# - Reading (DataSetSource):
+#     - MPI is used to split the data into chunks for each process, which are attempted
+#       to be read into memory in full in one go (assuming file source).
+#     - If the available physical memory is not enough, it tries to read smaller parts
+#       until it works. That part must be bigger or equal to the block size.
+#     - The loader can be wrapped or implement this interface as well
+#
+# - Writing:
+#     - data for the full chunk is allocated when the first block is written (so size in the
+#       non-sliced dimensions are known, as well as data type)
+#     - the number of slices per chunk has to be set/known somehow before
+#     - it tries to allocate the full chunk in-memory
+#     - if the allocation fails, it tries to reduce the slice and go through a file
+#       for swapping data in/out of memory
+#
+# - Reslicing:
+#     - for the loader interface, the slicing dim is simply set before the first read
+#       and the data is read in that slicing direction
+#     - for an implementation that is both a sink and a source,
+#       after writing of the full chunk is done (or when a file is the basis),
+#       `store.slicing_dim = 2` can be set to trigger a potential reslice
+#     - if data is all file-based, nothing really changes - blocks will be read in
+#       the other dimension with no issue
+#     - if data chunk is in memory, an MPI reslice operation is triggered to update
+#       the chunk
+
+# Notes for implementation:
+# - the BlockSplitter / BlockAggregator will read/write into these interfaces
+# - after Aggregator has written to it, splitter can read from it in the next section
+# - the loader wrapper can implement the source interface
+# - perhaps the method wrapper for saving intermediate files could use an instance of
+#   the sink interface - with some setting to definitely write to files.
+#   finalize would take care of making sure that everything is written to disk in the end
+
+
+class DataSetSource(Protocol):
+    """MPI-aware source for full datasets, where each process handles a *chunk*, and
+    the data can be read in *blocks*, sliced in the given slicing dimension"""
+
+    @property
+    def dtype(self) -> np.dtype:
+        ...  # pragma: no cover
+    
+    @property
+    def global_shape(self) -> Tuple[int, int, int]:
+        """Global data shape across all processes that we eventually have to read."""
+        ...  # pragma: no cover
+
+    @property
+    def chunk_shape(self) -> Tuple[int, int, int]:
+        """Returns the shape of a chunk, i.e. the data processed in the current
+        MPI process (whether it fits in memory or not)"""
+        ...  # pragma: no cover
+
+    @property
+    def global_index(self) -> Tuple[int, int, int]:
+        """Returns the start index of the chunk within the global data array"""
+        ...  # pragma: no cover
+
+    @property
+    def slicing_dim(self) -> Literal[0, 1, 2]:
+        """Slicing dimension - 0, 1, or 2"""
+        ...  # pragma: no cover
+        
+    @property
+    def aux_data(self) -> AuxiliaryData:
+        """Auxiliary data"""
+        ...  # pragma: no cover
+        
+    def read_block(self, start: int, length: int) -> DataSetBlock:
+        """Reads a block from the dataset, starting at `start` of length `length`,
+        in the current slicing dimension. Note that `start` is chunk-based,
+        i.e. mean different things in different processes."""
+        ...  # pragma: no cover
+
+    def finalize(self):
+        """Method intended to be called after reading all blocks is done,
+        to give implementations a chance to close files, free memory, etc."""
+        ...  # pragma: no cover
+
+
+class DataSetSink(Protocol):
+    @property
+    def global_shape(self) -> Tuple[int, int, int]:
+        """Global data shape across all processes that we eventually have to write."""
+        ...  # pragma: no cover
+
+    @property
+    def chunk_shape(self) -> Tuple[int, int, int]:
+        """Returns the shape of a chunk, i.e. the data processed in the current
+        MPI process (whether it fits in memory or not)"""
+        ...  # pragma: no cover
+
+    @property
+    def global_index(self) -> Tuple[int, int, int]:
+        """Returns the start index of the chunk within the global data array"""
+        ...  # pragma: no cover
+
+    @property
+    def slicing_dim(self) -> Literal[0, 1, 2]:
+        """Slicing dimension - 0, 1, or 2"""
+        ...  # pragma: no cover
+
+    def write_block(self, block: DataSetBlock):
+        """Writes a block to the store, starting at the index in dataset.chunk_index,
+        in the current slicing dimension.
+
+        NOTE: Implementers should make sure to move the dataset to CPU if required -
+        it may be on GPU when this method is called."""
+        ...  # pragma: no cover
+
+    def finalize(self):
+        """Method intended to be called after writing all blocks is done,
+        to give implementations a chance to write everything to disk and close the file,
+        etc."""
+        ...  # pragma: no cover
+        
+class ReadableDataSetSink(DataSetSink):
+    """Interface for a DataSetSink that is store-based, i.e. where it's possible to construct
+       a reader from the same data store that this sink has been written first"""
+       
+    @abc.abstractmethod
+    def make_reader(
+        self, new_slicing_dim: Optional[Literal[0, 1, 2]] = None
+    ) -> DataSetSource:
+        """Method to make a source from this sink, which will read the data that was written,
+        possibly in a new slicing dimension"""
+        ...  # pragma: no cover
+
+
+class DummySink(DataSetSink):
+    """DataSetSink that does nothing - it purely drops the data"""
+
+    def __init__(self, slicing_dim: Literal[0, 1, 2]):
+        self._global_shape: Tuple[int, int, int] = (0, 0, 0)
+        self._chunk_index: Tuple[int, int, int] = (0, 0, 0)
+        self._chunk_shape: Tuple[int, int, int] = (0, 0, 0)
+        self._slicing_dim = slicing_dim
+
+    @property
+    def global_shape(self) -> Tuple[int, int, int]:
+        return self._global_shape
+
+    @property
+    def chunk_shape(self) -> Tuple[int, int, int]:
+        return self._chunk_shape
+
+    @property
+    def global_index(self) -> Tuple[int, int, int]:
+        return self._chunk_index
+
+    @property
+    def slicing_dim(self) -> Literal[0, 1, 2]:
+        return self._slicing_dim
+
+    def write_block(self, block: DataSetBlock):
+        self._global_shape = block.global_shape
+        self._chunk_shape = block.chunk_shape
+        self._chunk_index = block.chunk_index
+
+    def finalize(self):
+        pass  # pragma: no cover
diff --git a/httomo/runner/loader.py b/httomo/runner/loader.py
index c1e466861..4f1f3f74a 100644
--- a/httomo/runner/loader.py
+++ b/httomo/runner/loader.py
@@ -1,129 +1,36 @@
-from typing import Any, Dict, Protocol
-from mpi4py import MPI
-from mpi4py.MPI import Comm
-from httomo.data.hdf._utils.chunk import get_data_shape_and_offset
-from httomo.data.hdf.loaders import LoaderData
-from httomo.runner.dataset import DataSet
-from httomo.runner.methods_repository_interface import MethodRepository
-from httomo.utils import Pattern, _get_slicing_dim
+from typing import Protocol
 
-
-import os
-
-from httomo.runner.backend_wrapper import BackendWrapper
+from httomo.runner.dataset_store_interfaces import DataSetSource
+from httomo.utils import Pattern
 
 
 class LoaderInterface(Protocol):
     """Interface to a loader object"""
 
+    # Patterns the loader supports
     pattern: Pattern = Pattern.all
-    reslice: bool = False
+    # purely informational, for use by the logger
     method_name: str
-    package_name: str = 'httomo'
+    package_name: str = "httomo"
 
-    def load(self) -> DataSet:
-        ...  # pragma: no cover
+    def make_data_source(self) -> DataSetSource:
+        """Create a dataset source that can produce blocks of data from the file.
 
-    def get_side_output(self) -> Dict[str, Any]:
+        This will be called after the patterns and sections have been determined,
+        just before the execution of the first section starts."""
         ...  # pragma: no cover
 
     @property
     def detector_x(self) -> int:
+        """detector x-dimension of the loaded data"""
         ...  # pragma: no cover
 
     @property
     def detector_y(self) -> int:
+        """detector y-dimension of the loaded data"""
         ...  # pragma: no cover
 
-
-class Loader(BackendWrapper, LoaderInterface):
-    """Using BackendWrapper for convenience only - it has all the logic
-    for loading a method and finding all the parameters, etc.
-    """
-
-    def __init__(
-        self,
-        method_repository: MethodRepository,
-        module_path: str,
-        method_name: str,
-        comm: Comm,
-        output_mapping: Dict[str, str] = {},
-        **kwargs,
-    ):
-        super().__init__(
-            method_repository, module_path, method_name, comm, output_mapping, **kwargs
-        )
-        self._detector_x = 0
-        self._detector_y = 0
-
-    def execute(self, dataset: DataSet) -> DataSet:
-        raise NotImplementedError("Cannot execute a loader - please call load")
-
-    def load(self) -> DataSet:
-        args = self._build_kwargs(self._transform_params(self._config_params))
-        ret: LoaderData = self.method(**args)
-        dataset = self._process_loader_data(ret)
-        dataset = self._postprocess_data(dataset)
-        return dataset
-
-    def _process_loader_data(self, ret: LoaderData) -> DataSet:
-        full_shape, start_indices = get_data_shape_and_offset(ret.data, _get_slicing_dim(self.pattern) - 1, self.comm)
-        dataset = DataSet(
-            data=ret.data, angles=ret.angles, flats=ret.flats, darks=ret.darks,
-            global_index=start_indices,
-            global_shape=full_shape
-        )
-        self._detector_x = ret.detector_x
-        self._detector_y = ret.detector_y
-        return dataset
-
-    @property
-    def detector_x(self) -> int:
-        return self._detector_x
-
     @property
-    def detector_y(self) -> int:
-        return self._detector_y
-
-
-def make_loader(
-    method_repository: MethodRepository,
-    module_path: str,
-    method_name: str,
-    comm: MPI.Comm,
-    **kwargs,
-) -> Loader:
-    """Factory function to generate the appropriate wrapper based on the module
-    path and method name for loaders.
-
-    Parameters
-    ----------
-
-    method_repository: MethodRepository
-        Repository of methods that we can use the query properties
-    module_path: str
-        Path to the module where the method is in python notation, e.g. "httomolibgpu.prep.normalize"
-    method_name: str
-        Name of the method (function within the given module)
-    comm: Comm
-        MPI communicator object
-    kwargs:
-        Arbitrary keyword arguments that get passed to the method as parameters.
-
-    Returns
-    -------
-
-    Loader
-        An instance of a loader class (which is also a BackendWrapper)
-    """
-
-    # note: once we have different kinds of loaders, this function can
-    # be used like the make_backend_wrapper factory function
-
-    return Loader(
-        method_repository=method_repository,
-        module_path=module_path,
-        method_name=method_name,
-        comm=comm,
-        **kwargs,
-    )
+    def angles_total(self) -> int:
+        """angles dimension of the loaded data"""
+        ...  # pragma: no cover
diff --git a/httomo/runner/method_wrapper.py b/httomo/runner/method_wrapper.py
new file mode 100644
index 000000000..1a0a319db
--- /dev/null
+++ b/httomo/runner/method_wrapper.py
@@ -0,0 +1,180 @@
+from dataclasses import dataclass
+from httomo.runner.dataset import DataSetBlock
+from httomo.runner.methods_repository_interface import GpuMemoryRequirement
+from httomo.utils import Pattern, xp
+
+import numpy as np
+from mpi4py import MPI
+
+import os
+from typing import Any, Callable, Dict, List, Literal, Optional, Protocol, Tuple, Union
+
+MethodParameterValues = Union[str, bool, int, float, os.PathLike, np.ndarray, xp.ndarray]
+MethodParameterDictType = Dict[str, Union[MethodParameterValues, List[MethodParameterValues]]]
+
+
+@dataclass
+class GpuTimeInfo:
+    """Captures the time spent on GPU"""
+    kernel: float = 0.0
+    host2device: float = 0.0
+    device2host: float = 0.0
+
+class MethodWrapper(Protocol):
+    """Interface for method wrappers, that is used by the pipeline and task runners to execute
+    methods in a generic way."""
+    
+    # read/write properties
+    task_id: str
+    pattern: Pattern
+    
+    # read-only properties
+    @property
+    def comm(self) -> MPI.Comm:
+        """The MPI communicator used"""
+        ...  # pragma: no cover
+    
+    @property
+    def method(self) -> Callable:
+        """The actual method underlying this wrapper"""
+        ...  # pragma: no cover
+    
+    @property
+    def parameters(self) -> List[str]:
+        """List of parameter names of the underlying method"""
+        ...  # pragma: nocover
+
+    @property
+    def memory_gpu(self) -> List[GpuMemoryRequirement]:
+        """Memory requirements for GPU execution"""
+        ...  # pragma: nocover
+        
+    @property
+    def implementation(self) -> Literal["gpu", "cpu", "gpu_cupy"]:
+        """Implementation of this method"""
+        ...  # pragma: nocover
+        
+    @property
+    def output_dims_change(self) -> bool:
+        """Whether output dimensions change after executing this method"""
+        ...  # pragma: nocover
+    
+    @property
+    def save_result(self) -> bool:
+        """Whether to save the result of this method to intermediate files"""
+        ... # pragma: nocover
+    
+    @property
+    def method_name(self) -> str:
+        """Returns the name of the method function"""
+        ... # pragma: nocover
+    
+    @property
+    def module_path(self) -> str:
+        """Returns the full module path where the method function is defined"""
+        ... # pragma: nocover
+    
+    @property
+    def package_name(self) -> str:
+        """The name of the top-level package where this method is implementated, e.g. 'httomolib'"""
+        ... # pragma: nocover
+    
+    @property
+    def cupyrun(self) -> bool:
+        """True if method runs on GPU and expects a CuPy array as inputs"""
+        ... # pragma: nocover
+
+    @property
+    def is_cpu(self) -> bool:
+        """True if this is a CPU-only method"""
+        ... # pragma: nocover
+
+    @property
+    def is_gpu(self) -> bool:
+        """True if this is a GPU method"""
+        ... # pragma: nocover
+    
+    @property
+    def gpu_time(self) -> GpuTimeInfo:
+        """Get the time spent on GPU in the last call to execute"""
+        ... # pragma: nocover
+        
+    @property
+    def config_params(self) -> Dict[str, Any]:
+        """Access a copy of the configuration parameters (cannot be modified directly)"""
+        ... # pragma: nocover
+        
+    @property
+    def recon_algorithm(self) -> Optional[str]:
+        """Determine the recon algorithm used, if the method is reconstruction.
+        Otherwise return None."""
+        ... # pragma: nocover
+
+    # Methods
+
+    def __getitem__(self, key: str) -> MethodParameterValues:
+        """Get a parameter for the method using dictionary notation (wrapper["param"])"""
+        ... # pragma: nocover
+
+    def __setitem__(self, key: str, value: MethodParameterValues):
+        """Set a parameter for the method using dictionary notation (wrapper["param"] = 42)"""
+        ... # pragma: nocover
+
+
+    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:
+        """Execute the method.
+
+        Parameters
+        ----------
+
+        block: DataSetBlock
+            A numpy or cupy dataset, mutable (method might work in-place).
+
+        Returns
+        -------
+
+        DataSetBlock
+            A CPU or GPU-based dataset object with the output
+        """
+        ... # pragma: nocover
+        
+    def get_side_output(self) -> Dict[str, Any]:
+        """Override this method for functions that have a side output. The returned dictionary
+        will be merged with the dict_params parameter passed to execute for all methods that
+        follow in the pipeline"""
+        ... # pragma: nocover
+        
+    def calculate_output_dims(
+        self, non_slice_dims_shape: Tuple[int, int]
+    ) -> Tuple[int, int]:
+        """Calculate the dimensions of the output for this method"""
+        ... # pragma: nocover
+        
+
+    def calculate_max_slices(
+        self,
+        data_dtype: np.dtype,
+        non_slice_dims_shape: Tuple[int, int],
+        available_memory: int,
+    ) -> Tuple[int, int]:
+        """If it runs on GPU, determine the maximum number of slices that can fit in the
+        available memory in bytes, and return a tuple of
+
+        (max_slices, available_memory)
+
+        The available memory may have been adjusted for the methods that follow, in case
+        something persists afterwards.
+        """
+        ... # pragma: nocover
+        
+        
+
+
+
+
+
diff --git a/httomo/runner/methods_repository_interface.py b/httomo/runner/methods_repository_interface.py
index c6e896ba2..5ad4a056f 100644
--- a/httomo/runner/methods_repository_interface.py
+++ b/httomo/runner/methods_repository_interface.py
@@ -36,6 +36,10 @@ def get_implementation(self) -> Literal["cpu", "gpu", "gpu_cupy"]:
     def get_memory_gpu_params(self) -> List[GpuMemoryRequirement]:
         """Get the parameters for the GPU memory estimation"""
         ...  # pragma: no cover
+        
+    def save_result_default(self) -> bool:
+        """Check if this method saves results by default"""
+        ...  # pragma: no cover
 
     def swap_dims_on_output(self) -> bool:
         """Check if the output 3D array needs to wap axis 0 and 1 to match httomolib.
diff --git a/httomo/runner/monitoring_interface.py b/httomo/runner/monitoring_interface.py
new file mode 100644
index 000000000..24d6690ad
--- /dev/null
+++ b/httomo/runner/monitoring_interface.py
@@ -0,0 +1,49 @@
+from typing import Protocol, TextIO, Tuple
+
+
+class MonitoringInterface(Protocol):
+    def report_method_block(
+        self,
+        method_name: str,
+        module_path: str,
+        task_id: str,
+        slicing_dim: int,
+        block_dims: Tuple[int, int, int],
+        block_idx_chunk: Tuple[int, int, int],
+        block_idx_global: Tuple[int, int, int],
+        cpu_time: float,
+        gpu_kernel_time: float = 0.0,
+        gpu_h2d_time: float = 0.0,
+        gpu_d2h_time: float = 0.0
+    ):
+        ... # pragma: no cover
+        
+    def report_source_block(
+        self,
+        name: str,
+        first_task_id: str,
+        slicing_dim: int,
+        block_dims: Tuple[int, int, int],
+        block_idx_chunk: Tuple[int, int, int],
+        block_idx_global: Tuple[int, int, int],
+        cpu_time: float
+    ):
+        ... # pragma: no cover
+        
+    def report_sink_block(
+        self,
+        name: str,
+        last_task_id: str,
+        slicing_dim: int,
+        block_dims: Tuple[int, int, int],
+        block_idx_chunk: Tuple[int, int, int],
+        block_idx_global: Tuple[int, int, int],
+        cpu_time: float
+    ):
+        ... # pragma: no cover
+        
+    def report_total_time(self, cpu_time: float):
+        ... # pragma: no cover
+        
+    def write_results(self, dest: TextIO):
+        ... # pragma: no cover
diff --git a/httomo/runner/output_ref.py b/httomo/runner/output_ref.py
index efa744255..b53e9e369 100644
--- a/httomo/runner/output_ref.py
+++ b/httomo/runner/output_ref.py
@@ -3,13 +3,13 @@
 if TYPE_CHECKING:
     # this is purely for type annotations. We import conditionally to
     # avoid circular imports (need to quote the type when used below)
-    from httomo.runner.backend_wrapper import BackendWrapper  # pragma: no cover
+    from httomo.runner.method_wrapper import MethodWrapper  # pragma: no cover
 
 
 class OutputRef:
     """Class to reference an output from other methods lazily."""
 
-    def __init__(self, method: "BackendWrapper", mapped_output_name: str):
+    def __init__(self, method: "MethodWrapper", mapped_output_name: str):
         self.method = method
         self.mapped_output_name = mapped_output_name
 
diff --git a/httomo/runner/pipeline.py b/httomo/runner/pipeline.py
index 2b119bc3b..640edf2dc 100644
--- a/httomo/runner/pipeline.py
+++ b/httomo/runner/pipeline.py
@@ -1,6 +1,6 @@
 from httomo.runner.loader import LoaderInterface
 from httomo.utils import Pattern
-from httomo.runner.backend_wrapper import BackendWrapper
+from httomo.runner.method_wrapper import MethodWrapper
 
 from typing import Iterator, List, Optional
 
@@ -12,30 +12,25 @@ class Pipeline:
     def __init__(
         self,
         loader: LoaderInterface,
-        methods: List[BackendWrapper],
-        save_results_set: List = [],
-        main_pipeline_start: int = 0,
+        methods: List[MethodWrapper]
     ):
         self._methods = methods
         self._loader = loader
-        self._save_results_set = save_results_set
-        self._main_pipeline_start = main_pipeline_start
 
     @property
     def loader(self) -> LoaderInterface:
         return self._loader
 
-    @property
-    def main_pipeline_start(self) -> int:
-        return self._main_pipeline_start
-
     # iterator interface to access the methods
-    def __iter__(self) -> Iterator[BackendWrapper]:
+    def __iter__(self) -> Iterator[MethodWrapper]:
         return iter(self._methods)
 
     def __len__(self) -> int:
         return len(self._methods)
 
+    def __getitem__(self, idx: int) -> MethodWrapper:
+        return self._methods[idx]
+
     @property
     def loader_pattern(self) -> Pattern:
         return self.loader.pattern
@@ -45,13 +40,3 @@ def loader_pattern(self, pattern: Pattern):
         """Although the pipeline is largely immutable, this setter is needed as the
         actual pattern is set after processing the full pipeline"""
         self.loader.pattern = pattern
-
-    @property
-    def loader_reslice(self) -> bool:
-        return self.loader.reslice if self.loader is not None else False
-
-    @loader_reslice.setter
-    def loader_reslice(self, reslice: bool):
-        """Although the pipeline is largely immutable, this setter is needed as the
-        information whether reslicing is required after the loader is set later"""
-        self.loader.reslice = reslice
diff --git a/httomo/runner/platform_section.py b/httomo/runner/section.py
similarity index 51%
rename from httomo/runner/platform_section.py
rename to httomo/runner/section.py
index 56e41e34b..e8bcb7117 100644
--- a/httomo/runner/platform_section.py
+++ b/httomo/runner/section.py
@@ -1,87 +1,76 @@
-from typing import Iterator, List
+from typing import Iterator, List, Optional
 
 import mpi4py
+from httomo.runner.output_ref import OutputRef
 from httomo.runner.pipeline import Pipeline
 from httomo.utils import Colour, Pattern, log_once
-from httomo.runner.backend_wrapper import BackendWrapper
+from httomo.runner.method_wrapper import MethodWrapper
 
 
-class PlatformSection:
+class Section:
     """Represents on section of a pipeline that can be executed on the same platform,
-    and has the same dataset pattern."""
+    and has the same dataset pattern.
+
+    A new section is added in with the following conditions:
+    - the pattern changed (sino->proj or vice versa)
+    - a side output of a previous method in the same section is referenced"""
 
     def __init__(
         self,
-        gpu: bool,
         pattern: Pattern,
-        reslice: bool,
         max_slices: int,
-        methods: List[BackendWrapper],
-        save_result: bool = False
+        methods: List[MethodWrapper],
+        is_last: bool = False,
     ):
-        self.gpu = gpu
         self.pattern = pattern
-        self.reslice = reslice
         self.max_slices = max_slices
         self.methods = methods
-        self.save_result = save_result
+        self.is_last = is_last
 
-    def __iter__(self) -> Iterator[BackendWrapper]:
+    def __iter__(self) -> Iterator[MethodWrapper]:
         return iter(self.methods)
 
     def __len__(self) -> int:
         return len(self.methods)
 
-    def __getitem__(self, idx: int) -> BackendWrapper:
+    def __getitem__(self, idx: int) -> MethodWrapper:
         return self.methods[idx]
 
 
-def sectionize(pipeline: Pipeline, save_all: bool = False) -> List[PlatformSection]:
-    sections: List[PlatformSection] = []
+def sectionize(pipeline: Pipeline) -> List[Section]:
+    """Separates the pipeline into sections,
+    where a new section is started whenever the pattern has changed
+    of when a side output from a previous method is referenced in the
+    method parameters."""
 
-    # The functions below are internal to reduce duplication
+    sections: List[Section] = []
 
-    def needs_global_input(method: BackendWrapper) -> bool:
-        return method.config_params.get("glob_stats", False)
+    # The functions below are internal to reduce duplication
 
     def is_pattern_compatible(a: Pattern, b: Pattern) -> bool:
         return a == Pattern.all or b == Pattern.all or a == b
 
     # loop carried variables, to build up the sections
-    current_gpu: bool = False
     current_pattern: Pattern = pipeline.loader_pattern
-    current_methods: List[BackendWrapper] = []
-    save_previous_result: bool = False
-
-    def finish_section(needs_reslice=False, save_previous_result=False):
-        if len(current_methods) > 0:
-            sections.append(
-                PlatformSection(
-                    current_gpu,
-                    current_pattern,
-                    needs_reslice,
-                    0,
-                    current_methods,
-                    save_result=save_previous_result,
-                )
-            )
-
-    for i, method in enumerate(pipeline):
-        pattern_changed = not is_pattern_compatible(current_pattern, method.pattern)
-        platform_changed = method.is_gpu != current_gpu
-        start_main_pipeline = i == pipeline.main_pipeline_start
-        global_input = needs_global_input(method)
-        start_new_section = (
-            global_input
-            or save_previous_result
-            or pattern_changed
-            or platform_changed
-            or start_main_pipeline
-        )
-
-        if start_new_section:
-            finish_section(pattern_changed, save_previous_result)
-            current_gpu = method.is_gpu
+    current_methods: List[MethodWrapper] = []
+
+    def references_previous_method(method: MethodWrapper) -> bool:
+        # find output references in the method's parameters
+        refs = [v for v in method.config_params.values() if isinstance(v, OutputRef)]
+        # see if any of them reference methods in the current method list
+        for r in refs:
+            if r.method in current_methods:
+                return True
+        return False
+
+    def finish_section():
+        sections.append(Section(current_pattern, 0, current_methods))
+
+    for method in pipeline:
+        if not is_pattern_compatible(
+            current_pattern, method.pattern
+        ) or references_previous_method(method):
+            finish_section()
             if method.pattern != Pattern.all:
                 current_pattern = method.pattern
             current_methods = [method]
@@ -89,12 +78,9 @@ def finish_section(needs_reslice=False, save_previous_result=False):
             current_methods.append(method)
             if current_pattern == Pattern.all:
                 current_pattern = method.pattern
-        if save_all:
-            save_previous_result = True
-        else:
-            save_previous_result = pipeline._save_results_set[i]
 
-    finish_section(save_previous_result=save_previous_result)
+    finish_section()
+    sections[-1].is_last = True
 
     _backpropagate_section_patterns(pipeline, sections)
     _finalize_patterns(pipeline, sections)
@@ -103,9 +89,7 @@ def finish_section(needs_reslice=False, save_previous_result=False):
     return sections
 
 
-def _backpropagate_section_patterns(
-    pipeline: Pipeline, sections: List[PlatformSection]
-):
+def _backpropagate_section_patterns(pipeline: Pipeline, sections: List[Section]):
     """Performs a backward sweep through the patterns of each section, propagating
     from the last section backwards in case the previous ones have Pattern.all.
     This makes sure the loader eventually gets the pattern that the section that follows
@@ -120,13 +104,11 @@ def _backpropagate_section_patterns(
         last_pattern = s.pattern
     if pipeline.loader_pattern == Pattern.all:
         pipeline.loader_pattern = last_pattern
-    elif pipeline.loader_pattern != last_pattern:
-        pipeline.loader_reslice = True
 
 
 def _finalize_patterns(
     pipeline: Pipeline,
-    sections: List[PlatformSection],
+    sections: List[Section],
     default_pattern=Pattern.projection,
 ):
     # final possible ambiguity: everything is Pattern.all -> pick projection by default
@@ -145,7 +127,7 @@ def _finalize_patterns(
     assert pipeline.loader_pattern != Pattern.all
 
 
-def _set_method_patterns(sections: List[PlatformSection]):
+def _set_method_patterns(sections: List[Section]):
     for s in sections:
         for m in s:
-            m.pattern = s.pattern
\ No newline at end of file
+            m.pattern = s.pattern
diff --git a/httomo/runner/task_runner.py b/httomo/runner/task_runner.py
index 4a2c1090a..22ce7c1c9 100644
--- a/httomo/runner/task_runner.py
+++ b/httomo/runner/task_runner.py
@@ -1,23 +1,33 @@
 from itertools import islice
-import math
 import time
-from typing import Any, Dict, Optional, List, Tuple, Union
+from typing import Any, Dict, Literal, Optional, List, Union
 import os
 from mpi4py import MPI
-import numpy as np
 import httomo
 import logging
-from httomo.data.hdf._utils.reslice import reslice, reslice_filebased
-from httomo.data.hdf._utils.save import intermediate_dataset
-from httomo.runner.backend_wrapper import BackendWrapper
-from httomo.runner.block_split import BlockAggregator, BlockSplitter
-from httomo.runner.dataset import DataSet
+from httomo.data.dataset_store import DataSetStoreWriter
+from httomo.runner.method_wrapper import MethodWrapper
+from httomo.runner.block_split import BlockSplitter
+from httomo.runner.dataset import DataSetBlock
+from httomo.runner.dataset_store_interfaces import (
+    DataSetSink,
+    DataSetSource,
+    DummySink,
+    ReadableDataSetSink,
+)
 from httomo.runner.gpu_utils import get_available_gpu_memory, gpumem_cleanup
-from httomo.runner.loader import LoaderInterface
+from httomo.runner.monitoring_interface import MonitoringInterface
 from httomo.runner.pipeline import Pipeline
-from httomo.runner.platform_section import PlatformSection, sectionize
-from httomo.utils import Colour, Pattern, _get_slicing_dim, log_exception, log_once
-
+from httomo.runner.section import Section, sectionize
+from httomo.utils import (
+    Colour,
+    Pattern,
+    _get_slicing_dim,
+    catchtime,
+    log_exception,
+    log_once,
+)
+import numpy as np
 
 log = logging.getLogger(__name__)
 
@@ -28,112 +38,56 @@ class TaskRunner:
     def __init__(
         self,
         pipeline: Pipeline,
-        save_all: bool = False,
-        reslice_dir: Optional[os.PathLike] = None,
+        reslice_dir: os.PathLike,
+        memory_limit_bytes: int = 0,
+        monitor: Optional[MonitoringInterface] = None,
     ):
         self.pipeline = pipeline
-        self.save_all = save_all
         self.reslice_dir = reslice_dir
         self.comm = MPI.COMM_WORLD
+        self.monitor = monitor
 
-        self.start_time: float = 0
-        self.global_stats: List = []
         self.side_outputs: Dict[str, Any] = dict()
-        self.dataset: Optional[DataSet] = None
-        self.method_index: int = 1
-        self.reslice_count: int = 0
-        self.has_reslice_warn_printed: bool = False
+        self.source: Optional[DataSetSource] = None
+        self.sink: Optional[Union[DataSetSink, ReadableDataSetSink]] = None
+
+        self._memory_limit_bytes = memory_limit_bytes
 
         self.output_colour_list = [Colour.GREEN, Colour.CYAN, Colour.GREEN]
         self.output_colour_list_short = [Colour.GREEN, Colour.CYAN]
 
-    def execute(self):
-        self.start_time = MPI.Wtime()
+    def execute(self) -> None:
+        with catchtime() as t:
+            sections = self._sectionize()
 
-        sections = sectionize(self.pipeline, self.save_all)
+            self._prepare()
+            for i, section in enumerate(sections):
+                self._execute_section(section, i)
+                gpumem_cleanup()
 
-        last_section: Optional[PlatformSection] = None
-        self._prepare()
-        for i, section in enumerate(sections):
-            if last_section is not None:
-                self.perform_reslice_if_needed(
-                    last_section=last_section, current_section=section
-                )
-            self._execute_section(section, i)
-            gpumem_cleanup()
-            self.check_save_intermediate_results(last_section=section)
-            self.method_index += len(section)
-            last_section = section
+        self._log_pipeline(f"Pipeline finished. Took {t.elapsed:.3f}s")
+        if self.monitor is not None:
+            self.monitor.report_total_time(t.elapsed)
 
-    def check_save_intermediate_results(self, last_section: PlatformSection):
-        if not last_section.save_result:
-            return
-        last_method = last_section.methods[-1]
-        if (
-            last_method.method_name == "save_to_images"
-            or "center" in last_method.method_name
-        ):
-            return
-        if self.dataset.data.ndim != 3:
-            return
-
-        slice_dim = _get_slicing_dim(last_section.pattern)
-
-        intermediate_dataset(
-            self.dataset.data,
-            httomo.globals.run_out_dir,
-            self.dataset.angles,
-            self.pipeline.loader.detector_x,
-            self.pipeline.loader.detector_y,
-            self.comm,
-            self.method_index,
-            last_method.package_name,
-            last_method.method_name,
-            "tomo",
-            slice_dim,
-            last_method.recon_algorithm,
-        )
-
-    def perform_reslice_if_needed(
-        self,
-        last_section: Optional[PlatformSection],
-        current_section: Optional[PlatformSection],
-    ):
-        if last_section is None or not last_section.reslice:
-            return
-        current_slice_dim = _get_slicing_dim(last_section.pattern)
-        next_slice_dim = _get_slicing_dim(current_section.pattern)
-        self.reslice_count += 1
-        if self.reslice_count > 1 and not self.has_reslice_warn_printed:
+    def _sectionize(self) -> List[Section]:
+        sections = sectionize(self.pipeline)
+        self._log_pipeline(f"Pipeline has been separated into {len(sections)} sections")
+        num_stores = len(sections) - 1
+        if num_stores > 3:
             log_once(
-                f"WARNING: Reslicing is performed {self.reslice_count} times. The number of reslices increases the total run time.",
+                f"WARNING: Data saving or/and reslicing operation will be performed {num_stores} times. This increases the total run time, especially if the saving is performed through the disk.",
                 comm=self.comm,
                 colour=Colour.RED,
             )
-            self.has_reslice_warn_printed = True
-
-        self.dataset.to_cpu()
 
-        if self.reslice_dir is None:
-            self.dataset.data = reslice(
-                self.dataset.data, current_slice_dim, next_slice_dim, self.comm
-            )[0]
-        else:
-            self.dataset.data = reslice_filebased(
-                self.dataset.data,
-                current_slice_dim,
-                next_slice_dim,
-                self.dataset.angles,
-                self.dataset.__dict__['_global_shape'][2],
-                self.dataset.__dict__['_global_shape'][1],
-                self.comm,
-                self.reslice_dir,
-            )[0]
+        return sections
 
-    def _execute_section(self, section: PlatformSection, section_index: int = 0):
-        assert self.dataset is not None, "Dataset has not been loaded yet"
+    def _execute_section(self, section: Section, section_index: int = 0):
+        self._setup_source_sink(section)
+        assert self.source is not None, "Dataset has not been loaded yet"
+        assert self.sink is not None, "Sink setup failed"
 
-        slicing_dim_section = _get_slicing_dim(section.pattern) - 1
+        slicing_dim_section: Literal[0, 1] = _get_slicing_dim(section.pattern) - 1  # type: ignore
         self.determine_max_slices(section, slicing_dim_section)
 
         self._log_pipeline(
@@ -141,25 +95,67 @@ def _execute_section(self, section: PlatformSection, section_index: int = 0):
             level=1,
         )
 
-        splitter = BlockSplitter(self.dataset, section.pattern, section.max_slices)
-        aggregator = BlockAggregator(self.dataset, section.pattern)
+        splitter = BlockSplitter(self.source, section.max_slices)
+        start_source = time.perf_counter_ns()
         for block in splitter:
-            aggregator.append(self._execute_section_block(section, block))
+            end_source = time.perf_counter_ns()
+            if self.monitor is not None:
+                self.monitor.report_source_block(
+                    f"sec_{section_index}",
+                    section.methods[0].task_id if len(section) > 0 else "",
+                    _get_slicing_dim(section.pattern) - 1,
+                    block.shape,
+                    block.chunk_index,
+                    block.global_index,
+                    (end_source - start_source) * 1e-9,
+                )
+
+            block = self._execute_section_block(section, block)
+
+            start_sink = time.perf_counter_ns()
+            self.sink.write_block(block)
+            end_sink = time.perf_counter_ns()
+            if self.monitor is not None:
+                self.monitor.report_sink_block(
+                    f"sec_{section_index}",
+                    section.methods[-1].task_id if len(section) > 0 else "",
+                    _get_slicing_dim(section.pattern) - 1,
+                    block.shape,
+                    block.chunk_index,
+                    block.global_index,
+                    (end_sink - start_sink) * 1e-9,
+                )
             gpumem_cleanup()
+            start_source = time.perf_counter_ns()
 
-        self.dataset = aggregator.full_dataset
+    def _setup_source_sink(self, section: Section):
+        assert self.source is not None, "Dataset has not been loaded yet"
 
-    def _execute_section_block(
-        self, section: PlatformSection, block: DataSet
-    ) -> DataSet:
-        for i, m in enumerate(section):
-            log.debug(
-                f"{m.method_name}: input shape, dtype: {block.data.shape}, {block.data.dtype}"
-            )
-            block = self._execute_method(m, self.method_index + i, block)
-            log.debug(
-                f"{m.method_name}: output shape, dtype: {block.data.shape}, {block.data.dtype}"
+        slicing_dim_section: Literal[0, 1] = _get_slicing_dim(section.pattern) - 1  # type: ignore
+
+        if self.sink is not None:
+            # we have a store-based sink from the last section - use that to determine
+            # the source for this one
+            assert isinstance(self.sink, ReadableDataSetSink)
+            self.source = self.sink.make_reader(slicing_dim_section)
+
+        if section.is_last:
+            # we don't need to store the results - this sink just discards it
+            self.sink = DummySink(slicing_dim_section)
+        else:
+            self.sink = DataSetStoreWriter(
+                slicing_dim_section,
+                self.comm,
+                self.reslice_dir,
+                memory_limit_bytes=self._memory_limit_bytes,
             )
+
+    def _execute_section_block(
+        self, section: Section, block: DataSetBlock
+    ) -> DataSetBlock:
+        for method in section:
+            self.set_side_inputs(method)
+            block = self._execute_method(method, block)
         return block
 
     def _log_pipeline(self, str: str, level: int = 0, colour=Colour.BVIOLET):
@@ -175,57 +171,85 @@ def _prepare(self):
 
     def _load_datasets(self):
         start_time = self._log_task_start(
-            self.method_index,
+            "loader",
             self.pipeline.loader.pattern,
             self.pipeline.loader.method_name,
         )
-        self.dataset = self.pipeline.loader.load()
-        self.update_side_inputs(self.pipeline.loader.get_side_output())
+        self.source = self.pipeline.loader.make_data_source()
         self._log_task_end(
-            self.method_index,
+            "loader",
             start_time,
             self.pipeline.loader.pattern,
             self.pipeline.loader.method_name,
-            self.pipeline.loader.package_name
+            self.pipeline.loader.package_name,
         )
-        self.method_index += 1
 
     def _execute_method(
-        self, method: BackendWrapper, num: int, dataset: DataSet
-    ) -> DataSet:
-        start_time = self._log_task_start(num, method.pattern, method.method_name)
-        dataset = method.execute(dataset)
-        if dataset.is_last_in_chunk:
-            self.update_side_inputs(method.get_side_output())
-        self._log_task_end(num, start_time, method.pattern, method.method_name, method.package_name)
-        return dataset
-
-    def update_side_inputs(self, side_outputs: Dict[str, Any]):
-        """Updates the methods not yet executed in the pipeline with the side outputs
-        gathered so far if needed"""
+        self, method: MethodWrapper, block: DataSetBlock
+    ) -> DataSetBlock:
+        start_time = self._log_task_start(
+            method.task_id, method.pattern, method.method_name
+        )
+        start = time.perf_counter_ns()
+        block = method.execute(block)
+        if block.is_last_in_chunk:
+            self.append_side_outputs(method.get_side_output())
+        end = time.perf_counter_ns()
+        if self.monitor is not None:
+            self.monitor.report_method_block(
+                method.method_name,
+                method.module_path,
+                method.task_id,
+                _get_slicing_dim(method.pattern) - 1,
+                block.shape,
+                block.chunk_index,
+                block.global_index,
+                (end - start) * 1e-9,
+                method.gpu_time.kernel,
+                method.gpu_time.host2device,
+                method.gpu_time.device2host,
+            )
+        self._log_task_end(
+            method.task_id,
+            start_time,
+            method.pattern,
+            method.method_name,
+            method.package_name,
+        )
+        return block
+
+    def append_side_outputs(self, side_outputs: Dict[str, Any]):
+        """Appends to the side outputs that are available for the next section(s)"""
         if len(side_outputs) == 0:
             return
 
         self.side_outputs |= side_outputs
 
-        # iterate starting after current method index
-        for m in islice(self.pipeline, self.method_index - 1, None):
-            for k, v in self.side_outputs.items():
-                if k in m.parameters:
-                    m[k] = v
+    def set_side_inputs(self, method: MethodWrapper):
+        """Sets the parameters that reference side outputs for this method."""
+        for k, v in self.side_outputs.items():
+            if k in method.parameters:
+                method[k] = v
 
-    def _log_task_start(self, num: int, pattern: Pattern, name: str) -> int:
+    def _log_task_start(self, id: str, pattern: Pattern, name: str) -> int:
         log_once(
-            f"Running task {num} (pattern={pattern.name}): {name}...",
+            f"Running {id} (pattern={pattern.name}): {name}...",
             self.comm,
             colour=Colour.LIGHT_BLUE,
             level=0,
         )
         return time.perf_counter_ns()
 
-    def _log_task_end(self, num: int, start_time: int, pattern: Pattern, name: str, package: str = 'httomo'):
+    def _log_task_end(
+        self,
+        id: str,
+        start_time: int,
+        pattern: Pattern,
+        name: str,
+        package: str = "httomo",
+    ):
         output_str_list = [
-            f"    Finished task {num} (pattern={pattern.name}): {name} (",
+            f"    Finished {id} (pattern={pattern.name}): {name} (",
             package,
             f") Took {float(time.perf_counter_ns() - start_time)*1e-6:.2f}ms",
         ]
@@ -250,19 +274,18 @@ def _check_params_for_sweep(self):
     def _count_tuple_values(self, d: Dict[str, Any]) -> int:
         return sum(1 for v in d.values() if isinstance(v, tuple))
 
-    def determine_max_slices(self, section: PlatformSection, slicing_dim: int):
-        data_shape = self.dataset.data.shape
+    def determine_max_slices(self, section: Section, slicing_dim: int):
+        assert self.source is not None
+        data_shape = self.source.chunk_shape
 
         max_slices = data_shape[slicing_dim]
-        if not section.gpu:
-            section.max_slices = max_slices
+        if len(section) == 0:
+            section.max_slices = min(httomo.globals.MAX_CPU_SLICES, max_slices)
             return
 
-        ###### GPU ##################
-
         nsl_dim_l = list(data_shape)
         nsl_dim_l.pop(slicing_dim)
-        non_slice_dims_shape = tuple(nsl_dim_l)
+        non_slice_dims_shape = (nsl_dim_l[0], nsl_dim_l[1])
 
         available_memory = get_available_gpu_memory(10.0)
         available_memory_in_GB = round(available_memory / (1024**3), 2)
@@ -270,19 +293,37 @@ def determine_max_slices(self, section: PlatformSection, slicing_dim: int):
             f"The amount of the available GPU memory is {available_memory_in_GB} GB"
         )
         log_once(memory_str, comm=self.comm, colour=Colour.BVIOLET, level=1)
+        if self._memory_limit_bytes != 0:
+            available_memory = min(available_memory, self._memory_limit_bytes)
+            log_once(
+                f"The memory has been limited to {available_memory / (1024**3):4.2f} GB",
+                comm=self.comm,
+                colour=Colour.BVIOLET,
+                level=1,
+            )
+
         max_slices_methods = [max_slices] * len(section)
 
         # loop over all methods in section
+        has_gpu = False
         for idx, m in enumerate(section):
             if len(m.memory_gpu) == 0:
                 max_slices_methods[idx] = max_slices
                 continue
 
+            has_gpu = has_gpu or m.is_gpu
             output_dims = m.calculate_output_dims(non_slice_dims_shape)
             (slices_estimated, available_memory) = m.calculate_max_slices(
-                self.dataset, non_slice_dims_shape, available_memory
+                self.source.dtype,
+                non_slice_dims_shape,
+                available_memory,
             )
             max_slices_methods[idx] = min(max_slices, slices_estimated)
             non_slice_dims_shape = output_dims
 
-        section.max_slices = min(max_slices_methods)
+        if not has_gpu:
+            section.max_slices = min(
+                min(max_slices_methods), httomo.globals.MAX_CPU_SLICES
+            )
+        else:
+            section.max_slices = min(max_slices_methods)
diff --git a/httomo/transform_layer.py b/httomo/transform_layer.py
new file mode 100644
index 000000000..9d73a9c36
--- /dev/null
+++ b/httomo/transform_layer.py
@@ -0,0 +1,70 @@
+import os
+from pathlib import Path
+from typing import Optional
+from httomo.method_wrappers import make_method_wrapper
+from httomo.methods_database.query import MethodDatabaseRepository
+from httomo.runner.pipeline import Pipeline
+from mpi4py import MPI
+import httomo
+
+
+class TransformLayer:
+    def __init__(
+        self,
+        repo=MethodDatabaseRepository(),
+        comm: MPI.Comm = MPI.COMM_WORLD,
+        save_all=False,
+        out_dir: Optional[os.PathLike] = None,
+    ):
+        self._repo = repo
+        self._save_all = save_all
+        self._comm = comm
+        self._out_dir = out_dir if out_dir is not None else httomo.globals.run_out_dir
+
+    def transform(self, pipeline: Pipeline) -> Pipeline:
+        pipeline = self.insert_save_methods(pipeline)
+        pipeline = self.insert_data_reducer(pipeline)
+        return pipeline
+
+    def insert_save_methods(self, pipeline: Pipeline) -> Pipeline:
+        loader = pipeline.loader
+        methods = []
+        for m in pipeline:
+            methods.append(m)
+            if (
+                (m.save_result or self._save_all)
+                and m.method_name != "save_to_images"
+                and "center" not in m.method_name
+            ):
+                methods.append(
+                    make_method_wrapper(
+                        self._repo,
+                        "httomo.methods",
+                        "save_intermediate_data",
+                        comm=self._comm,
+                        save_result=False,
+                        loader=loader,
+                        prev_method=m,
+                        task_id=f"save_{m.task_id}",
+                        out_dir=self._out_dir,
+                    )
+                )
+        return Pipeline(loader, methods)
+
+    def insert_data_reducer(self, pipeline: Pipeline) -> Pipeline:
+        """This will always insert data reducer as method 0 right after the loader"""
+        loader = pipeline.loader
+        methods = []
+        methods.append(
+            make_method_wrapper(
+                self._repo,
+                "httomolib.misc.morph",
+                "data_reducer",
+                comm=self._comm,
+                save_result=False,
+                task_id=f"task_{0}",
+            ),
+        )
+        for m in pipeline:
+            methods.append(m)
+        return Pipeline(loader, methods)
diff --git a/httomo/transform_loader_params.py b/httomo/transform_loader_params.py
new file mode 100644
index 000000000..6b5a9db39
--- /dev/null
+++ b/httomo/transform_loader_params.py
@@ -0,0 +1,118 @@
+from typing import Literal, Optional, TypeAlias, TypedDict, Union
+
+from httomo.loaders.types import (
+    AnglesConfig,
+    RawAngles,
+    UserDefinedAngles,
+)
+from httomo.preview import PreviewConfig, PreviewDimConfig
+
+
+class StartStopEntry(TypedDict):
+    start: Optional[int]
+    stop: Optional[int]
+
+
+PreviewParamEntry: TypeAlias = Union[Literal["mid"], StartStopEntry]
+
+
+class PreviewParam(TypedDict):
+    angles: Optional[StartStopEntry]
+    detector_y: Optional[PreviewParamEntry]
+    detector_x: Optional[PreviewParamEntry]
+
+
+def parse_preview(
+    param_value: Optional[PreviewParam],
+    data_shape: tuple[int, int, int],
+) -> PreviewConfig:
+    DIMENSION_MAPPINGS: dict[str, int] = {"angles": 0, "detector_y": 1, "detector_x": 2}
+
+    if param_value is None:
+        param_value = {"angles": None, "detector_y": None, "detector_x": None}
+
+    dims = param_value.keys()
+    for dim in DIMENSION_MAPPINGS.keys():
+        if dim not in dims:
+            param_value[dim] = None
+
+    if param_value["angles"] == "mid":
+        raise ValueError("'mid' keyword not supported for angles dimension")
+
+    dim_configs = {}
+    for dim, slice_info in param_value.items():
+        if slice_info is None:
+            start = 0
+            stop = data_shape[DIMENSION_MAPPINGS[dim]]
+        elif slice_info == "mid":
+            start, stop = _get_middle_slice_indices(data_shape[DIMENSION_MAPPINGS[dim]])
+        else:
+            start = slice_info.get("start", 0)
+            stop = slice_info.get("stop", data_shape[DIMENSION_MAPPINGS[dim]])
+
+        dim_configs[dim] = PreviewDimConfig(start=start, stop=stop)
+
+    return PreviewConfig(
+        angles=dim_configs["angles"],
+        detector_y=dim_configs["detector_y"],
+        detector_x=dim_configs["detector_x"],
+    )
+
+
+def _get_middle_slice_indices(dim_len: int) -> tuple[int, int]:
+    """
+    Get indices for middle 3 slices of either the detector_y or detetcor_x dimension.
+
+    For even length dimensions, will return the 4 indices corresponding to:
+    - dim_len // 2 - 2
+    - dim_len // 2 - 1
+    - dim_len // 2
+    - dim_len // 2 + 1
+
+    For odd length dimensions, will return the 3 indices corresponding to:
+    - dim_len // 2 - 1
+    - dim_len // 2
+    - dim_len // 2 + 1
+    """
+    mid_slice_idx = dim_len // 2
+
+    if mid_slice_idx == 1:
+        return 0, dim_len
+
+    if dim_len % 2 == 0:
+        return mid_slice_idx - 2, mid_slice_idx + 1
+    else:
+        return mid_slice_idx - 1, mid_slice_idx + 1
+
+
+class RawAnglesParam(TypedDict):
+    data_path: str
+
+
+class UserDefinedAnglesParamInner(TypedDict):
+    start_angle: int
+    stop_angle: int
+    angles_total: int
+
+
+class UserDefinedAnglesParam(TypedDict):
+    user_defined: UserDefinedAnglesParamInner
+
+
+AnglesParam: TypeAlias = Union[RawAnglesParam, UserDefinedAnglesParam]
+
+
+def parse_angles(angles_data: AnglesParam) -> AnglesConfig:
+    keys = angles_data.keys()
+
+    if "data_path" in keys:
+        return RawAngles(data_path=angles_data["data_path"])
+
+    if "user_defined" in keys:
+        return UserDefinedAngles(
+            start_angle=angles_data["user_defined"]["start_angle"],
+            stop_angle=angles_data["user_defined"]["stop_angle"],
+            angles_total=angles_data["user_defined"]["angles_total"],
+        )
+
+    raise ValueError(f"Unknown rotation_angles param value for loader: {angles_data}")
diff --git a/httomo/ui_layer.py b/httomo/ui_layer.py
index 00507e972..97799eaf8 100644
--- a/httomo/ui_layer.py
+++ b/httomo/ui_layer.py
@@ -1,43 +1,50 @@
+from itertools import islice
 import yaml
-from typing import Any, Dict, List, Protocol
+from typing import Any, Dict, List, Optional, Protocol, Tuple
 from importlib import import_module, util
 from pathlib import Path
 import os
 import re
 
+import h5py
 from mpi4py import MPI
 from mpi4py.MPI import Comm
+from httomo.darks_flats import DarksFlatsFileConfig
 
 from httomo.methods_database.query import MethodDatabaseRepository
+from httomo.runner.method_wrapper import MethodWrapper
 from httomo.runner.pipeline import Pipeline
 
-from httomo.runner.backend_wrapper import make_backend_wrapper
-from httomo.runner.loader import make_loader
+from httomo.method_wrappers import make_method_wrapper
+from httomo.loaders import make_loader
+from httomo.runner.loader import LoaderInterface
 from httomo.runner.output_ref import OutputRef
+from httomo.transform_loader_params import parse_angles, parse_preview
+
 
 class UiLayer:
     """A common user interface for different front-ends in httomo.
     We currently support YAML and Python interfaces, but in future
-    different UI's can be added based on other data formats, e.g. JSON. 
+    different UI's can be added based on other data formats, e.g. JSON.
     """
-    
+
     def __init__(
         self,
         tasks_file_path: Path,
         in_data_file_path: Path,
         comm: Comm,
-    ):   
-        
-        self.repo = MethodDatabaseRepository()
+        repo=MethodDatabaseRepository(),
+    ):
+        self.repo = repo
         self.tasks_file_path = tasks_file_path
         self.in_data_file = in_data_file_path
         self.comm = comm
-        
+
         root, ext = os.path.splitext(self.tasks_file_path)
-        if ext in ['.yaml', '.yaml'.upper()]:
+        if ext.upper() in [".YAML", ".YML"]:
             # loading yaml file with tasks provided
-            self.PipelineStageConfig = _yaml_loader(self.tasks_file_path)[0]
-        elif ext in ['.py', '.py'.upper()]:
+            self.PipelineStageConfig = _yaml_loader(self.tasks_file_path)
+        elif ext.upper() == ".PY":
             # loading python file with tasks provided
             self.PipelineStageConfig = _python_tasks_loader(self.tasks_file_path)
         else:
@@ -46,67 +53,116 @@ def __init__(
             )
 
     def build_pipeline(self) -> Pipeline:
-        side_outputs_collect: list = [] # saves [task_no, id, side_outputs] for tasks with side_outputs
-        save_result_collect: list = [] 
-        methods_list: list = []
-        for task_no, task_conf in enumerate(self.PipelineStageConfig):
-            if "loaders" in task_conf['module_path']:
-                task_conf['parameters']['in_file'] = self.in_data_file
-                # unpack params and initiate a loader
-                loader =  make_loader(
-                        self.repo,
-                        task_conf['module_path'],
-                        task_conf['method'],
-                        self.comm,
-                        **task_conf['parameters'],
-                        )
-            else:
-                if "parameters" not in task_conf:
-                    task_conf['parameters'] = {}
-                if "save_result" not in task_conf:
-                    save_result_collect.append(False)
-                else:
-                    save_result_collect.append(task_conf['save_result'])
-                if "side_outputs" not in task_conf:
-                    task_conf['side_outputs'] = {}
-                else:
-                    side_outputs_collect.append([task_no, task_conf['id'], task_conf['side_outputs']])
-                # check if there is a reference to side_outputs to cross-link
-                for key, value in task_conf['parameters'].items():
-                    if isinstance(value, str) and value is not None:
-                        if value.find('${{') != -1:                            
-                            result_extr = re.search(r"\{([A-Za-z0-9_.]+)\}", value)
-                            internal_expression = result_extr.group(1)
-                            (ref_id, side_str, ref_arg) = internal_expression.split(".")
-                            # lets find the referred id in "side_outputs_collect"
-                            for items in side_outputs_collect:
-                                if items[1] == ref_id:
-                                    # refer to methods_list[items[0]-1]
-                                    task_conf['parameters'][key] = OutputRef(methods_list[items[0]-1], ref_arg)
-                # unpack params of a method and append to a list of methods
-                method = make_backend_wrapper(
-                    self.repo,
-                    task_conf['module_path'],
-                    task_conf['method'],
-                    self.comm,
-                    task_conf['side_outputs'],
-                    **task_conf['parameters'],
-                )
-                methods_list.append(method)
-        return Pipeline(
-            loader=loader,
-            methods=methods_list,
-            save_results_set=save_result_collect,
+        loader = self._setup_loader()
+
+        # saves map {task_id -> method} map
+        methods_list: List[MethodWrapper] = []
+        method_id_map: Dict[str, MethodWrapper] = dict()
+        for i, task_conf in enumerate(self.PipelineStageConfig[1:]):
+            parameters = task_conf.get("parameters", dict())
+            _update_side_output_references(parameters, method_id_map)
+            # unpack params of a method and append to a list of methods
+            method = make_method_wrapper(
+                method_repository=self.repo,
+                module_path=task_conf["module_path"],
+                method_name=task_conf["method"],
+                comm=self.comm,
+                save_result=task_conf.get("save_result", None),
+                output_mapping=task_conf.get("side_outputs", dict()),
+                task_id=task_conf.get("id", f"task_{i+1}"),
+                **parameters,
+            )
+            if method.task_id in method_id_map:
+                raise ValueError(f"duplicate id {method.task_id} in pipeline")
+            method_id_map[method.task_id] = method
+            methods_list.append(method)
+
+        return Pipeline(loader=loader, methods=methods_list)
+
+    def _setup_loader(self) -> LoaderInterface:
+        task_conf = self.PipelineStageConfig[0]
+        if "loaders" not in task_conf["module_path"]:
+            raise ValueError("Got pipeline with no loader (must be first method)")
+        parameters = task_conf.get("parameters", dict())
+        parameters["in_file"] = self.in_data_file
+        # the following will raise KeyError if not present
+        in_file = parameters["in_file"]
+        data_path = parameters["data_path"]
+        # these will have defaults if not given
+        image_key_path = parameters.get("image_key_path", None)
+        darks: dict = parameters.get("darks", dict())
+        darks_file = darks.get("file", in_file)
+        darks_path = darks.get("data_path", data_path)
+        darks_image_key = darks.get("image_key_path", image_key_path)
+        flats: dict = parameters.get("flats", dict())
+        flats_file = flats.get("file", in_file)
+        flats_path = flats.get("data_path", data_path)
+        flats_image_key = flats.get("image_key_path", image_key_path)
+        angles = parse_angles(parameters["rotation_angles"])
+
+        with h5py.File(in_file, "r") as f:
+            data_shape = f[data_path].shape
+        preview = parse_preview(parameters.get("preview", None), data_shape)
+
+        loader = make_loader(
+            repo=self.repo,
+            module_path=task_conf["module_path"],
+            method_name=task_conf["method"],
+            in_file=Path(in_file),
+            data_path=data_path,
+            image_key_path=image_key_path,
+            angles=angles,
+            darks=DarksFlatsFileConfig(
+                file=darks_file, data_path=darks_path, image_key_path=darks_image_key
+            ),
+            flats=DarksFlatsFileConfig(
+                file=flats_file, data_path=flats_path, image_key_path=flats_image_key
+            ),
+            preview=preview,
+            comm=self.comm,
         )
 
+        return loader
+
+
+def _update_side_output_references(
+    parameters: dict, method_id_map: Dict[str, MethodWrapper]
+):
+    pattern = re.compile(r"^\$\{\{([A-Za-z0-9_.]+)\}\}$")
+    # check if there is a reference to side_outputs to cross-link
+    for key, value in parameters.items():
+        if not isinstance(value, str) or value is None:
+            continue
+        result_extr = pattern.search(value)
+        if result_extr is None:
+            continue
+        internal_expression = result_extr.group(1)
+        (ref_id, side_str, ref_arg) = internal_expression.split(".")
+        if side_str != "side_outputs":
+            raise ValueError(
+                "Config error: output references must be of the form <id>.side_outputs.<name>"
+            )
+
+        method = method_id_map.get(ref_id, None)
+        if method is None:
+            raise ValueError(
+                f"could not find method referenced by {internal_expression}"
+            )
+
+        parameters[key] = OutputRef(method, ref_arg)
+
+
 def _yaml_loader(file_path: Path) -> list:
     with open(file_path, "r") as f:
         tasks_list = list(yaml.load_all(f, Loader=yaml.FullLoader))
-    return tasks_list
+    return tasks_list[0]
+
 
 def _python_tasks_loader(file_path: Path) -> list:
     module_spec = util.spec_from_file_location("methods_to_list", file_path)
+    assert module_spec is not None, "error reading module spec"
     foo = util.module_from_spec(module_spec)
+    assert module_spec.loader is not None, "module spec has no loader"
     module_spec.loader.exec_module(foo)
     tasks_list = list(foo.methods_to_list())
-    return tasks_list
\ No newline at end of file
+    return tasks_list
diff --git a/httomo/utils.py b/httomo/utils.py
index a30febbd3..d2b84ceed 100644
--- a/httomo/utils.py
+++ b/httomo/utils.py
@@ -1,9 +1,12 @@
+from contextlib import contextmanager
 import re
 from enum import Enum
 from pathlib import Path
-from typing import Any, Callable, Dict, List, Tuple
+from time import perf_counter_ns
+from typing import Any, Callable, Dict, List, Literal, Tuple
 
 from mpi4py.MPI import Comm
+import numpy as np
 
 import httomo.globals
 from httomo.data import mpiutil
@@ -213,7 +216,7 @@ class Pattern(Enum):
     all = 2
 
 
-def _get_slicing_dim(pattern: Pattern) -> int:
+def _get_slicing_dim(pattern: Pattern) -> Literal[1, 2]:
     """Assuming 3D projection data with the axes ordered as
     - axis 0 = rotation angle
     - axis 1 = detector y
@@ -279,3 +282,56 @@ def remove_ansi_escape_sequences(filename):
         with open(filename, "w") as f:
             for line in lines:
                 f.write(ansi_escape.sub("", line))
+
+
+class catchtime:
+    """A context manager to measure ns-accurate time for the context block.
+    
+    Usage:
+        with catchtime() as t:
+            ...
+            
+        print(t.elapsed)
+    """
+    def __enter__(self):
+        self.start = perf_counter_ns()
+        return self
+
+    def __exit__(self, type, value, traceback):
+        self.elapsed = (perf_counter_ns() - self.start) * 1e-9
+    
+
+class catch_gputime:
+    def __enter__(self):
+        if gpu_enabled:
+            self.start = xp.cuda.Event()
+            self.start.record()
+        return self
+            
+    def __exit__(self, type, value, traceback):
+        if gpu_enabled:
+            self.end = xp.cuda.Event()
+            self.end.record()
+    
+    @property
+    def elapsed(self) -> float:
+        if gpu_enabled:
+            self.end.synchronize()
+            return xp.cuda.get_elapsed_time(self.start, self.end) * 1e-3
+        else:
+            return 0.0
+
+
+def make_3d_shape_from_shape(shape: List[int]) -> Tuple[int, int, int]:
+    """Given a shape as a list of length 3, return a corresponding tuple 
+       with the right typing type (required to make mypy type checks work)
+    """
+    assert len(shape) == 3, "3D shape expected"
+    return (shape[0], shape[1], shape[2])
+
+
+def make_3d_shape_from_array(array: np.ndarray) -> Tuple[int, int, int]:
+    """Given a 3D array, return a corresponding shape tuple
+       with the right typing type (required to make mypy type checks work)
+    """
+    return make_3d_shape_from_shape(list(array.shape))
diff --git a/pyproject.toml b/pyproject.toml
index 916d117ea..14b1bbe44 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -90,6 +90,7 @@ dev-cpu = [
   "httomolib[dev]",
   "tomopy",
   "plumbum",
+  "typing_extensions",
   "mypy",
   "types-PyYAML",
 ]
@@ -106,6 +107,7 @@ dev-gpu = [
   "httomolibgpu[dev]",
   "tomopy",
   "plumbum",
+  "typing_extensions",
   "mypy",
   "types-PyYAML",
 ]
@@ -113,7 +115,8 @@ dev-gpu = [
 [tool.mypy]
 # Ignore missing stubs for modules we use
 ignore_missing_imports = true
-python_version = 3.9  # make sure we're compatible with 3.9
+python_version = "3.9"  # make sure we're compatible with 3.9
+plugins = 'numpy.typing.mypy_plugin'
 
 [tool.isort]
 profile = "black"
diff --git a/samples/loader_configs/diad.yaml b/samples/loader_configs/diad.yaml
index e4b04c54f..d2423d2e6 100644
--- a/samples/loader_configs/diad.yaml
+++ b/samples/loader_configs/diad.yaml
@@ -9,8 +9,7 @@
       data_path: /entry/imaging_sum/gts_theta_value
     dimension: 1
     preview:
-      -
-      - start: 5
+      detector_y:
+        start: 5
         stop: 7
-      -
     pad: 0
diff --git a/samples/loader_configs/ebic.yaml b/samples/loader_configs/ebic.yaml
index 115242004..3529e438d 100644
--- a/samples/loader_configs/ebic.yaml
+++ b/samples/loader_configs/ebic.yaml
@@ -13,5 +13,4 @@
         file: # /path/to/file/containing/flats
         data_path: /exchange/data_white_post
     dimension: 1
-    preview: [None, None, None]
-    pad: 0
\ No newline at end of file
+    pad: 0
diff --git a/samples/loader_configs/standard_tomo.yaml b/samples/loader_configs/standard_tomo.yaml
index cbd0f94dc..eb60ec2a6 100644
--- a/samples/loader_configs/standard_tomo.yaml
+++ b/samples/loader_configs/standard_tomo.yaml
@@ -9,8 +9,7 @@
       data_path: /entry1/tomo_entry/data/rotation_angle
     dimension: 1
     preview:
-      -
-      - start: 57
+      detector_y:
+        start: 57
         stop: 60
-      -
     pad: 0
diff --git a/samples/pipeline_template_examples/DLS/01_diad_pipeline_gpu.yaml b/samples/pipeline_template_examples/DLS/01_diad_pipeline_gpu.yaml
index 85f52724a..2b50021b9 100644
--- a/samples/pipeline_template_examples/DLS/01_diad_pipeline_gpu.yaml
+++ b/samples/pipeline_template_examples/DLS/01_diad_pipeline_gpu.yaml
@@ -8,10 +8,9 @@
         data_path: /entry/imaging_sum/gts_theta_value
     dimension: 1
     preview:
-      -
-      - start: 8
+      detector_y:
+        start: 8
         stop: 15
-      -
     pad: 0
 - method: find_center_vo
   module_path: httomolibgpu.recon.rotation
@@ -26,11 +25,12 @@
   id: centering
   side_outputs:
     cor: centre_of_rotation
-- method: remove_outlier3d
+- method: remove_outlier
   module_path: httomolibgpu.misc.corr
   parameters:
     dif: 0.1
     kernel_size: 3
+    axis: auto
 - method: normalize
   module_path: httomolibgpu.prep.normalize
   parameters:
@@ -47,6 +47,7 @@
   module_path: httomolibgpu.recon.algorithm
   parameters:
     center: ${{centering.side_outputs.centre_of_rotation}}
+    filter_freq_cutoff: 0.6
     recon_size: null
     recon_mask_radius: null
   save_result: true
@@ -60,7 +61,7 @@
   module_path: httomolib.misc.images
   parameters:
     subfolder_name: images
-    axis: 1
+    axis: auto
     file_format: tif
     bits: 8
     perc_range_min: 0.0
diff --git a/samples/pipeline_template_examples/DLS/03_i12_separate_darks_flats.yaml b/samples/pipeline_template_examples/DLS/03_i12_separate_darks_flats.yaml
index 35255291e..b44d14fe8 100644
--- a/samples/pipeline_template_examples/DLS/03_i12_separate_darks_flats.yaml
+++ b/samples/pipeline_template_examples/DLS/03_i12_separate_darks_flats.yaml
@@ -10,10 +10,6 @@
         stop_angle: 180
         angles_total: 724
     dimension: 1
-    preview:
-      -
-      - 
-      -
     pad: 0
     darks:
       file: tests/test_data/i12/separate_flats_darks/dark_field.h5
diff --git a/samples/pipeline_template_examples/DLS/04_i12_ignore_darks_flats.yaml b/samples/pipeline_template_examples/DLS/04_i12_ignore_darks_flats.yaml
index 8d0d8239c..d0834b762 100644
--- a/samples/pipeline_template_examples/DLS/04_i12_ignore_darks_flats.yaml
+++ b/samples/pipeline_template_examples/DLS/04_i12_ignore_darks_flats.yaml
@@ -10,10 +10,6 @@
         stop_angle: 180
         angles_total: 724
     dimension: 1
-    preview:
-      -
-      - 
-      -
     pad: 0
     darks:
       file: tests/test_data/i12/separate_flats_darks/dark_field.h5
diff --git a/samples/pipeline_template_examples/pipeline_360deg_gpu2.yaml b/samples/pipeline_template_examples/pipeline_360deg_gpu2.yaml
index 0297685b2..daf20d34c 100644
--- a/samples/pipeline_template_examples/pipeline_360deg_gpu2.yaml
+++ b/samples/pipeline_template_examples/pipeline_360deg_gpu2.yaml
@@ -6,12 +6,6 @@
     image_key_path: entry1/tomo_entry/instrument/detector/image_key
     rotation_angles:
       data_path: /entry1/tomo_entry/data/rotation_angle
-    dimension: 1
-    preview:
-      -
-      -
-      -
-    pad: 0
 - method: find_center_360
   module_path: httomolibgpu.recon.rotation
   parameters:
@@ -27,11 +21,12 @@
     overlap: overlap
     side: side
     overlap_position: overlap_position
-- method: remove_outlier3d
+- method: remove_outlier
   module_path: httomolibgpu.misc.corr
   parameters:
     dif: 0.1
     kernel_size: 3
+    axis: auto
 - method: normalize
   module_path: httomolibgpu.prep.normalize
   parameters:
@@ -53,6 +48,7 @@
   module_path: httomolibgpu.recon.algorithm
   parameters:
     center: ${{centering.side_outputs.centre_of_rotation}}
+    filter_freq_cutoff: 0.6
     recon_size: null
     recon_mask_radius: null
   save_result: true
@@ -66,7 +62,7 @@
   module_path: httomolib.misc.images
   parameters:
     subfolder_name: images
-    axis: 1
+    axis: auto
     file_format: tif
     bits: 8
     perc_range_min: 0.0
diff --git a/samples/pipeline_template_examples/pipeline_360deg_iterative_gpu3.yaml b/samples/pipeline_template_examples/pipeline_360deg_iterative_gpu3.yaml
new file mode 100644
index 000000000..4a548d5e7
--- /dev/null
+++ b/samples/pipeline_template_examples/pipeline_360deg_iterative_gpu3.yaml
@@ -0,0 +1,80 @@
+- 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
+    preview:
+      -
+      - start: 1000
+        stop: 1005
+      -
+    pad: 0
+- method: find_center_360
+  module_path: httomolibgpu.recon.rotation
+  parameters:
+    ind: mid
+    win_width: 10
+    side: null
+    denoise: true
+    norm: false
+    use_overlap: false
+  id: centering
+  side_outputs:
+    cor: centre_of_rotation
+    overlap: overlap
+    side: side
+    overlap_position: overlap_position
+- method: normalize
+  module_path: httomolibgpu.prep.normalize
+  parameters:
+    cutoff: 10.0
+    minus_log: true
+    nonnegativity: false
+    remove_nans: false
+- method: sino_360_to_180
+  module_path: httomolibgpu.misc.morph
+  parameters:
+    overlap: ${{centering.side_outputs.overlap}}
+    rotation: right
+- method: remove_all_stripe
+  module_path: httomolibgpu.prep.stripe
+  parameters:
+    snr: 3.0
+    la_size: 61
+    sm_size: 21
+    dim: 1
+- method: CGLS
+  module_path: httomolibgpu.recon.algorithm
+  parameters:
+    center: ${{centering.side_outputs.centre_of_rotation}}
+    recon_size: null
+    iterations: 3
+    nonnegativity: true
+  save_result: true
+- method: data_resampler
+  module_path: httomolibgpu.misc.morph
+  parameters:
+    newshape: [256, 256]
+    axis: auto
+    interpolation: nearest  
+- method: calculate_stats
+  module_path: httomo.methods
+  parameters: {}
+  id: statistics
+  side_outputs:
+    glob_stats: glob_stats  
+- method: save_to_images
+  module_path: httomolib.misc.images
+  parameters:
+    subfolder_name: images
+    axis: auto
+    file_format: tif
+    bits: 8
+    perc_range_min: 0.0
+    perc_range_max: 90.0
+    jpeg_quality: 95
+    glob_stats: ${{statistics.side_outputs.glob_stats}}
diff --git a/samples/pipeline_template_examples/pipeline_cpu1.yaml b/samples/pipeline_template_examples/pipeline_cpu1.yaml
index ec416f3ce..622088800 100644
--- a/samples/pipeline_template_examples/pipeline_cpu1.yaml
+++ b/samples/pipeline_template_examples/pipeline_cpu1.yaml
@@ -7,10 +7,6 @@
     rotation_angles:
       data_path: /entry1/tomo_entry/data/rotation_angle
     dimension: 1
-    preview:
-      - 
-      - 
-      - 
     pad: 0
 - method: normalize
   module_path: tomopy.prep.normalize
@@ -44,7 +40,7 @@
   module_path: httomolib.misc.images
   parameters:
     subfolder_name: images
-    axis: 1
+    axis: auto
     file_format: tif
     bits: 8
     perc_range_min: 0.0
diff --git a/samples/pipeline_template_examples/pipeline_cpu2.yaml b/samples/pipeline_template_examples/pipeline_cpu2.yaml
index e221e5c59..3d77c8e95 100644
--- a/samples/pipeline_template_examples/pipeline_cpu2.yaml
+++ b/samples/pipeline_template_examples/pipeline_cpu2.yaml
@@ -8,10 +8,9 @@
         data_path: /entry1/tomo_entry/data/rotation_angle
     dimension: 1
     preview:
-      -
-      - start: 30
+      detector_y:
+        start: 30
         stop: 60
-      -
     pad: 0
 - method: find_center_vo
   module_path: tomopy.recon.rotation
@@ -31,7 +30,7 @@
   parameters:
     dif: 0.1
     size: 3
-    axis: 0
+    axis: auto
 - method: normalize
   module_path: tomopy.prep.normalize
   parameters:
@@ -60,12 +59,12 @@
   module_path: tomopy.misc.corr
   parameters:
     size: 3
-    axis: 0
+    axis: auto
 - method: save_to_images
   module_path: httomolib.misc.images
   parameters:
     subfolder_name: images
-    axis: 1
+    axis: auto
     file_format: tif
     bits: 8
     perc_range_min: 0.0
diff --git a/samples/pipeline_template_examples/pipeline_cpu3.yaml b/samples/pipeline_template_examples/pipeline_cpu3.yaml
index 7a1b90f1d..865957736 100644
--- a/samples/pipeline_template_examples/pipeline_cpu3.yaml
+++ b/samples/pipeline_template_examples/pipeline_cpu3.yaml
@@ -7,10 +7,6 @@
     rotation_angles:
         data_path: /entry1/tomo_entry/data/rotation_angle
     dimension: 1
-    preview:
-      -
-      -
-      -
     pad: 0
 - method: find_center_vo
   module_path: tomopy.recon.rotation
@@ -30,7 +26,7 @@
   parameters:
     dif: 0.1
     size: 3
-    axis: 0
+    axis: auto
 - method: normalize
   module_path: tomopy.prep.normalize
   parameters:
@@ -57,7 +53,7 @@
   module_path: httomolib.misc.images
   parameters:
     subfolder_name: images
-    axis: 1
+    axis: auto
     file_format: tif
     bits: 8
     perc_range_min: 0.0
diff --git a/samples/pipeline_template_examples/pipeline_cpu4.yaml b/samples/pipeline_template_examples/pipeline_cpu4.yaml
new file mode 100644
index 000000000..e8e2eb8be
--- /dev/null
+++ b/samples/pipeline_template_examples/pipeline_cpu4.yaml
@@ -0,0 +1,43 @@
+- 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
+- method: find_center_pc
+  module_path: tomopy.recon.rotation
+  parameters:
+    proj1: auto
+    proj2: auto
+    tol: 0.5
+    rotc_guess: null
+  id: centering
+  side_outputs:
+    cor: centre_of_rotation
+- method: normalize
+  module_path: tomopy.prep.normalize
+  parameters:
+    cutoff: null
+    averaging: mean
+- method: minus_log
+  module_path: tomopy.prep.normalize
+  parameters: {}
+- method: recon
+  module_path: tomopy.recon.algorithm
+  parameters:
+    center: ${{centering.side_outputs.centre_of_rotation}}
+    sinogram_order: false
+    algorithm: 'gridrec'
+    init_recon: null
+- method: save_to_images
+  module_path: httomolib.misc.images
+  parameters:
+    subfolder_name: images
+    axis: auto
+    file_format: tif
+    bits: 8
+    perc_range_min: 0.0
+    perc_range_max: 100.0
+    jpeg_quality: 95
diff --git a/samples/pipeline_template_examples/pipeline_gpu1.yaml b/samples/pipeline_template_examples/pipeline_gpu1.yaml
index 154cf6aa1..09c939799 100644
--- a/samples/pipeline_template_examples/pipeline_gpu1.yaml
+++ b/samples/pipeline_template_examples/pipeline_gpu1.yaml
@@ -7,10 +7,6 @@
     rotation_angles:
       data_path: /entry1/tomo_entry/data/rotation_angle
     dimension: 1
-    preview:
-      -
-      -
-      -
     pad: 0
 - method: find_center_vo
   module_path: httomolibgpu.recon.rotation
@@ -25,11 +21,12 @@
   id: centering
   side_outputs:
     cor: centre_of_rotation
-- method: remove_outlier3d
+- method: remove_outlier
   module_path: httomolibgpu.misc.corr
   parameters:
     dif: 0.1
     kernel_size: 3
+    axis: auto
 - method: normalize
   module_path: httomolibgpu.prep.normalize
   parameters:
@@ -46,6 +43,7 @@
   module_path: httomolibgpu.recon.algorithm
   parameters:
     center: ${{centering.side_outputs.centre_of_rotation}}
+    filter_freq_cutoff: 0.6
     recon_size: null
     recon_mask_radius: null
   save_result: true
@@ -53,7 +51,7 @@
   module_path: httomolib.misc.images
   parameters:
     subfolder_name: images
-    axis: 1
+    axis: auto
     file_format: tif
     bits: 8
     perc_range_min: 0.0
diff --git a/samples/python_templates/pipeline_cpu1.py b/samples/python_templates/pipeline_cpu1.py
index 1f0e32ebb..980619262 100644
--- a/samples/python_templates/pipeline_cpu1.py
+++ b/samples/python_templates/pipeline_cpu1.py
@@ -26,7 +26,6 @@ def methods_to_list() -> PipelineConfig:
                      'image_key_path': 'entry1/tomo_entry/instrument/detector/image_key',
                      'rotation_angles': {"data_path": "/entry1/tomo_entry/data/rotation_angle"},
                      'dimension': 1,
-                     'preview': [None, None, None],
                      'pad': 0,
                        },
                     }
diff --git a/samples/python_templates/pipeline_cpu2.py b/samples/python_templates/pipeline_cpu2.py
index 3c50fe259..0dd7f3727 100644
--- a/samples/python_templates/pipeline_cpu2.py
+++ b/samples/python_templates/pipeline_cpu2.py
@@ -27,7 +27,7 @@ def methods_to_list() -> PipelineConfig:
             "image_key_path": "entry1/tomo_entry/instrument/detector/image_key",
             "rotation_angles": {"data_path": "/entry1/tomo_entry/data/rotation_angle"},
             "dimension": 1,
-            "preview": [None, {"start": 30, "stop": 60}, None],
+            "preview": {"detector_y": {"start": 30, "stop": 60}},
             "pad": 0,
         },
     }
diff --git a/samples/python_templates/pipeline_cpu3.py b/samples/python_templates/pipeline_cpu3.py
index 018fcd554..bd0756546 100644
--- a/samples/python_templates/pipeline_cpu3.py
+++ b/samples/python_templates/pipeline_cpu3.py
@@ -27,7 +27,6 @@ def methods_to_list() -> PipelineConfig:
             "image_key_path": "entry1/tomo_entry/instrument/detector/image_key",
             "rotation_angles": {"data_path": "/entry1/tomo_entry/data/rotation_angle"},
             "dimension": 1,
-            "preview": [None, None, None],
             "pad": 0,
         },
     }
diff --git a/samples/python_templates/pipeline_gpu1.py b/samples/python_templates/pipeline_gpu1.py
index c68ddb1c5..f96f07d7b 100644
--- a/samples/python_templates/pipeline_gpu1.py
+++ b/samples/python_templates/pipeline_gpu1.py
@@ -27,7 +27,6 @@ def methods_to_list() -> PipelineConfig:
             "image_key_path": "entry1/tomo_entry/instrument/detector/image_key",
             "rotation_angles": {"data_path": "/entry1/tomo_entry/data/rotation_angle"},
             "dimension": 1,
-            "preview": [None, None, None],
             "pad": 0,
         },
     }
@@ -49,7 +48,7 @@ def methods_to_list() -> PipelineConfig:
     }
     full_pipeline_list.append(method1)
     method2 = {
-        "method": "remove_outlier3d",
+        "method": "remove_outlier",
         "module_path": "httomolibgpu.misc.corr",
         "parameters": {
             "dif": 0.1,
diff --git a/samples/samples_info.txt b/samples/samples_info.txt
index fe76086f6..b00b97644 100755
--- a/samples/samples_info.txt
+++ b/samples/samples_info.txt
@@ -13,7 +13,7 @@ DLS-specific:
 
 python_templates:
   - pipeline_cpu1.py (same pipeline as pipeline_cpu1.yaml)
-  - pipeline_cpu2.py (same pipeline as pipeline_cpu1.yaml)
-  - pipeline_cpu3.py (same pipeline as pipeline_cpu1.yaml)
-  - pipeline_gpu1.py (same pipeline as pipeline_cpu1.yaml)
+  - pipeline_cpu2.py (same pipeline as pipeline_cpu2.yaml)
+  - pipeline_cpu3.py (same pipeline as pipeline_cpu3.yaml)
+  - pipeline_gpu1.py (same pipeline as pipeline_gpu1.yaml)
 
diff --git a/templates/httomolib/1.2/httomolib.misc.corr/inpainting_filter3d.yaml b/templates/httomolib/1.2/httomolib.misc.corr/inpainting_filter3d.yaml
deleted file mode 100644
index 08d1d1f0f..000000000
--- a/templates/httomolib/1.2/httomolib.misc.corr/inpainting_filter3d.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-- method: inpainting_filter3d
-  module_path: httomolib.misc.corr
-  parameters:
-    mask: REQUIRED
-    iter: 3
-    windowsize_half: 5
-    method_type: random
diff --git a/templates/httomolib/1.2/httomolib.misc.images/save_to_images.yaml b/templates/httomolib/1.2/httomolib.misc.images/save_to_images.yaml
index ff7f70f27..047d0c372 100644
--- a/templates/httomolib/1.2/httomolib.misc.images/save_to_images.yaml
+++ b/templates/httomolib/1.2/httomolib.misc.images/save_to_images.yaml
@@ -2,10 +2,11 @@
   module_path: httomolib.misc.images
   parameters:
     subfolder_name: images
-    axis: 1
+    axis: auto
     file_format: tif
     bits: 8
     perc_range_min: 0.0
     perc_range_max: 100.0
     jpeg_quality: 95
     glob_stats: ${{statistics.side_outputs.glob_stats}}
+    asynchronous: true
diff --git a/templates/httomolib/1.2/httomolib.misc.morph/data_reducer.yaml b/templates/httomolib/1.2/httomolib.misc.morph/data_reducer.yaml
new file mode 100644
index 000000000..e449ef6d2
--- /dev/null
+++ b/templates/httomolib/1.2/httomolib.misc.morph/data_reducer.yaml
@@ -0,0 +1,5 @@
+- method: data_reducer
+  module_path: httomolib.misc.morph
+  parameters:
+    axis: 0
+    method: mean
diff --git a/templates/httomolib/1.2/httomolib.misc.segm/binary_thresholding.yaml b/templates/httomolib/1.2/httomolib.misc.segm/binary_thresholding.yaml
index 459aca568..0debaf0b0 100644
--- a/templates/httomolib/1.2/httomolib.misc.segm/binary_thresholding.yaml
+++ b/templates/httomolib/1.2/httomolib.misc.segm/binary_thresholding.yaml
@@ -4,4 +4,4 @@
     val_intensity: 0.1
     otsu: false
     foreground: true
-    axis: 1
+    axis: auto
diff --git a/templates/httomolib/httomolib_modules.yaml b/templates/httomolib/httomolib_modules.yaml
index 8fe8d7016..6b69f7d0f 100644
--- a/templates/httomolib/httomolib_modules.yaml
+++ b/templates/httomolib/httomolib_modules.yaml
@@ -1,4 +1,4 @@
-- httomolib.misc.corr
+- httomolib.misc.morph
 - httomolib.misc.images
 - httomolib.misc.segm
 - httomolib.prep.phase
diff --git a/templates/httomolibgpu/1.2/httomolibgpu.misc.corr/median_filter3d.yaml b/templates/httomolibgpu/1.2/httomolibgpu.misc.corr/median_filter.yaml
similarity index 68%
rename from templates/httomolibgpu/1.2/httomolibgpu.misc.corr/median_filter3d.yaml
rename to templates/httomolibgpu/1.2/httomolibgpu.misc.corr/median_filter.yaml
index 3e44f87f3..9b7c3bdd5 100644
--- a/templates/httomolibgpu/1.2/httomolibgpu.misc.corr/median_filter3d.yaml
+++ b/templates/httomolibgpu/1.2/httomolibgpu.misc.corr/median_filter.yaml
@@ -1,5 +1,6 @@
-- method: median_filter3d
+- method: median_filter
   module_path: httomolibgpu.misc.corr
   parameters:
     kernel_size: 3
+    axis: auto
     dif: 0.0
diff --git a/templates/httomolibgpu/1.2/httomolibgpu.misc.corr/remove_outlier3d.yaml b/templates/httomolibgpu/1.2/httomolibgpu.misc.corr/remove_outlier.yaml
similarity index 67%
rename from templates/httomolibgpu/1.2/httomolibgpu.misc.corr/remove_outlier3d.yaml
rename to templates/httomolibgpu/1.2/httomolibgpu.misc.corr/remove_outlier.yaml
index 066225b95..e7647420d 100644
--- a/templates/httomolibgpu/1.2/httomolibgpu.misc.corr/remove_outlier3d.yaml
+++ b/templates/httomolibgpu/1.2/httomolibgpu.misc.corr/remove_outlier.yaml
@@ -1,5 +1,6 @@
-- method: remove_outlier3d
+- method: remove_outlier
   module_path: httomolibgpu.misc.corr
   parameters:
     kernel_size: 3
+    axis: auto
     dif: 0.1
diff --git a/templates/httomolibgpu/1.2/httomolibgpu.misc.morph/data_resampler.yaml b/templates/httomolibgpu/1.2/httomolibgpu.misc.morph/data_resampler.yaml
new file mode 100644
index 000000000..8c22a27eb
--- /dev/null
+++ b/templates/httomolibgpu/1.2/httomolibgpu.misc.morph/data_resampler.yaml
@@ -0,0 +1,6 @@
+- method: data_resampler
+  module_path: httomolibgpu.misc.morph
+  parameters:
+    newshape: REQUIRED
+    axis: auto
+    interpolation: linear
diff --git a/templates/httomolibgpu/1.2/httomolibgpu.misc.rescale/rescale_to_int.yaml b/templates/httomolibgpu/1.2/httomolibgpu.misc.rescale/rescale_to_int.yaml
new file mode 100644
index 000000000..bbea1017d
--- /dev/null
+++ b/templates/httomolibgpu/1.2/httomolibgpu.misc.rescale/rescale_to_int.yaml
@@ -0,0 +1,7 @@
+- method: rescale_to_int
+  module_path: httomolibgpu.misc.rescale
+  parameters:
+    perc_range_min: 0.0
+    perc_range_max: 100.0
+    bits: 8
+    glob_stats: ${{statistics.side_outputs.glob_stats}}
diff --git a/templates/httomolibgpu/1.2/httomolibgpu.recon.algorithm/FBP.yaml b/templates/httomolibgpu/1.2/httomolibgpu.recon.algorithm/FBP.yaml
index 6798cfcc3..6a578b0dc 100644
--- a/templates/httomolibgpu/1.2/httomolibgpu.recon.algorithm/FBP.yaml
+++ b/templates/httomolibgpu/1.2/httomolibgpu.recon.algorithm/FBP.yaml
@@ -2,5 +2,6 @@
   module_path: httomolibgpu.recon.algorithm
   parameters:
     center: ${{centering.side_outputs.centre_of_rotation}}
+    filter_freq_cutoff: 0.6
     recon_size: null
     recon_mask_radius: null
diff --git a/templates/httomolibgpu/1.2/httomolibgpu.recon.rotation/find_center_pc.yaml b/templates/httomolibgpu/1.2/httomolibgpu.recon.rotation/find_center_pc.yaml
new file mode 100644
index 000000000..6777dea22
--- /dev/null
+++ b/templates/httomolibgpu/1.2/httomolibgpu.recon.rotation/find_center_pc.yaml
@@ -0,0 +1,10 @@
+- method: find_center_pc
+  module_path: httomolibgpu.recon.rotation
+  parameters:
+    proj1: auto
+    proj2: auto
+    tol: 0.5
+    rotc_guess: null
+  id: centering
+  side_outputs:
+    cor: centre_of_rotation
diff --git a/templates/httomolibgpu/httomolibgpu_modules.yaml b/templates/httomolibgpu/httomolibgpu_modules.yaml
index b025f5f98..d9eefb585 100644
--- a/templates/httomolibgpu/httomolibgpu_modules.yaml
+++ b/templates/httomolibgpu/httomolibgpu_modules.yaml
@@ -1,5 +1,6 @@
 - httomolibgpu.misc.corr
 - httomolibgpu.misc.morph
+- httomolibgpu.misc.rescale
 - httomolibgpu.prep.alignment
 - httomolibgpu.prep.normalize
 - httomolibgpu.prep.phase
diff --git a/templates/tomopy/1.14/tomopy.misc.corr/enhance_projs_aps_1id.yaml b/templates/tomopy/1.14/tomopy.misc.corr/enhance_projs_aps_1id.yaml
deleted file mode 100644
index 5a1ca23c6..000000000
--- a/templates/tomopy/1.14/tomopy.misc.corr/enhance_projs_aps_1id.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-- method: enhance_projs_aps_1id
-  module_path: tomopy.misc.corr
-  parameters:
-    imgstack: REQUIRED
-    median_ks: 5
diff --git a/templates/tomopy/1.14/tomopy.misc.corr/gaussian_filter.yaml b/templates/tomopy/1.14/tomopy.misc.corr/gaussian_filter.yaml
index 25189f57d..7e5baed7d 100644
--- a/templates/tomopy/1.14/tomopy.misc.corr/gaussian_filter.yaml
+++ b/templates/tomopy/1.14/tomopy.misc.corr/gaussian_filter.yaml
@@ -3,4 +3,4 @@
   parameters:
     sigma: 3
     order: 0
-    axis: 0
+    axis: auto
diff --git a/templates/tomopy/1.14/tomopy.misc.corr/median_filter.yaml b/templates/tomopy/1.14/tomopy.misc.corr/median_filter.yaml
index 4fa945a0d..25a00a3ba 100644
--- a/templates/tomopy/1.14/tomopy.misc.corr/median_filter.yaml
+++ b/templates/tomopy/1.14/tomopy.misc.corr/median_filter.yaml
@@ -2,4 +2,4 @@
   module_path: tomopy.misc.corr
   parameters:
     size: 3
-    axis: 0
+    axis: auto
diff --git a/templates/tomopy/1.14/tomopy.misc.corr/median_filter_cuda.yaml b/templates/tomopy/1.14/tomopy.misc.corr/median_filter_cuda.yaml
deleted file mode 100644
index b24512c35..000000000
--- a/templates/tomopy/1.14/tomopy.misc.corr/median_filter_cuda.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-- method: median_filter_cuda
-  module_path: tomopy.misc.corr
-  parameters:
-    size: 3
-    axis: 0
diff --git a/templates/tomopy/1.14/tomopy.misc.corr/remove_outlier.yaml b/templates/tomopy/1.14/tomopy.misc.corr/remove_outlier.yaml
index bcc85c2ad..505534a4b 100644
--- a/templates/tomopy/1.14/tomopy.misc.corr/remove_outlier.yaml
+++ b/templates/tomopy/1.14/tomopy.misc.corr/remove_outlier.yaml
@@ -3,4 +3,4 @@
   parameters:
     dif: REQUIRED
     size: 3
-    axis: 0
+    axis: auto
diff --git a/templates/tomopy/1.14/tomopy.misc.corr/remove_outlier1d.yaml b/templates/tomopy/1.14/tomopy.misc.corr/remove_outlier1d.yaml
index 2606001c3..f99d31a9b 100644
--- a/templates/tomopy/1.14/tomopy.misc.corr/remove_outlier1d.yaml
+++ b/templates/tomopy/1.14/tomopy.misc.corr/remove_outlier1d.yaml
@@ -3,4 +3,4 @@
   parameters:
     dif: REQUIRED
     size: 3
-    axis: 0
+    axis: auto
diff --git a/templates/tomopy/1.14/tomopy.misc.corr/remove_outlier_cuda.yaml b/templates/tomopy/1.14/tomopy.misc.corr/remove_outlier_cuda.yaml
deleted file mode 100644
index 4658d8f37..000000000
--- a/templates/tomopy/1.14/tomopy.misc.corr/remove_outlier_cuda.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-- method: remove_outlier_cuda
-  module_path: tomopy.misc.corr
-  parameters:
-    dif: REQUIRED
-    size: 3
-    axis: 0
diff --git a/templates/tomopy/1.14/tomopy.misc.corr/sobel_filter.yaml b/templates/tomopy/1.14/tomopy.misc.corr/sobel_filter.yaml
index 286bc165a..4c0b30229 100644
--- a/templates/tomopy/1.14/tomopy.misc.corr/sobel_filter.yaml
+++ b/templates/tomopy/1.14/tomopy.misc.corr/sobel_filter.yaml
@@ -1,4 +1,4 @@
 - method: sobel_filter
   module_path: tomopy.misc.corr
   parameters:
-    axis: 0
+    axis: auto
diff --git a/templates/tomopy/1.14/tomopy.misc.morph/downsample.yaml b/templates/tomopy/1.14/tomopy.misc.morph/downsample.yaml
index 596b18ad7..6a2e01519 100644
--- a/templates/tomopy/1.14/tomopy.misc.morph/downsample.yaml
+++ b/templates/tomopy/1.14/tomopy.misc.morph/downsample.yaml
@@ -2,4 +2,4 @@
   module_path: tomopy.misc.morph
   parameters:
     level: 1
-    axis: 2
+    axis: auto
diff --git a/templates/tomopy/1.14/tomopy.misc.morph/sino_360_t0_180.yaml b/templates/tomopy/1.14/tomopy.misc.morph/sino_360_t0_180.yaml
deleted file mode 100644
index ff5b71be7..000000000
--- a/templates/tomopy/1.14/tomopy.misc.morph/sino_360_t0_180.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-- method: sino_360_t0_180
-  module_path: tomopy.misc.morph
-  parameters:
-    overlap: 0
-    rotation: left
diff --git a/templates/tomopy/1.14/tomopy.misc.morph/sino_360_to_180.yaml b/templates/tomopy/1.14/tomopy.misc.morph/sino_360_to_180.yaml
index 14f6e9f26..b7d1c0de7 100644
--- a/templates/tomopy/1.14/tomopy.misc.morph/sino_360_to_180.yaml
+++ b/templates/tomopy/1.14/tomopy.misc.morph/sino_360_to_180.yaml
@@ -1,5 +1,5 @@
 - method: sino_360_to_180
   module_path: tomopy.misc.morph
   parameters:
-    overlap: 0
+    overlap: ${{centering.side_outputs.overlap}}
     rotation: left
diff --git a/templates/tomopy/1.14/tomopy.misc.morph/upsample.yaml b/templates/tomopy/1.14/tomopy.misc.morph/upsample.yaml
index b27b19f24..4168ba54a 100644
--- a/templates/tomopy/1.14/tomopy.misc.morph/upsample.yaml
+++ b/templates/tomopy/1.14/tomopy.misc.morph/upsample.yaml
@@ -2,4 +2,4 @@
   module_path: tomopy.misc.morph
   parameters:
     level: 1
-    axis: 2
+    axis: auto
diff --git a/templates/tomopy/1.14/tomopy.prep.alignment/calc_slit_box_aps_1id.yaml b/templates/tomopy/1.14/tomopy.prep.alignment/calc_slit_box_aps_1id.yaml
deleted file mode 100644
index 41b84453a..000000000
--- a/templates/tomopy/1.14/tomopy.prep.alignment/calc_slit_box_aps_1id.yaml
+++ /dev/null
@@ -1,9 +0,0 @@
-- method: calc_slit_box_aps_1id
-  module_path: tomopy.prep.alignment
-  parameters:
-    slit_box_corners: REQUIRED
-    inclip: !!python/tuple
-    - 1
-    - 10
-    - 1
-    - 10
diff --git a/templates/tomopy/1.14/tomopy.prep.alignment/find_slits_corners_aps_1id.yaml b/templates/tomopy/1.14/tomopy.prep.alignment/find_slits_corners_aps_1id.yaml
deleted file mode 100644
index 437baa475..000000000
--- a/templates/tomopy/1.14/tomopy.prep.alignment/find_slits_corners_aps_1id.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-- method: find_slits_corners_aps_1id
-  module_path: tomopy.prep.alignment
-  parameters:
-    img: REQUIRED
-    method: quadrant+
-    medfilt2_kernel_size: 3
-    medfilt_kernel_size: 23
diff --git a/templates/tomopy/1.14/tomopy.prep.alignment/load_distortion_coefs.yaml b/templates/tomopy/1.14/tomopy.prep.alignment/load_distortion_coefs.yaml
deleted file mode 100644
index b5fa275b1..000000000
--- a/templates/tomopy/1.14/tomopy.prep.alignment/load_distortion_coefs.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-- method: load_distortion_coefs
-  module_path: tomopy.prep.alignment
-  parameters:
-    file_path: REQUIRED
diff --git a/templates/tomopy/1.14/tomopy.prep.alignment/remove_slits_aps_1id.yaml b/templates/tomopy/1.14/tomopy.prep.alignment/remove_slits_aps_1id.yaml
deleted file mode 100644
index 73f25487f..000000000
--- a/templates/tomopy/1.14/tomopy.prep.alignment/remove_slits_aps_1id.yaml
+++ /dev/null
@@ -1,10 +0,0 @@
-- method: remove_slits_aps_1id
-  module_path: tomopy.prep.alignment
-  parameters:
-    imgstacks: REQUIRED
-    slit_box_corners: REQUIRED
-    inclip: !!python/tuple
-    - 1
-    - 10
-    - 1
-    - 10
diff --git a/templates/tomopy/1.14/tomopy.prep.alignment/tilt.yaml b/templates/tomopy/1.14/tomopy.prep.alignment/tilt.yaml
deleted file mode 100644
index 42aa746b8..000000000
--- a/templates/tomopy/1.14/tomopy.prep.alignment/tilt.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-- method: tilt
-  module_path: tomopy.prep.alignment
-  parameters:
-    obj: REQUIRED
-    rad: 0
-    phi: 0
diff --git a/templates/tomopy/1.14/tomopy.recon.algorithm/init_tomo.yaml b/templates/tomopy/1.14/tomopy.recon.algorithm/init_tomo.yaml
deleted file mode 100644
index c2efca89c..000000000
--- a/templates/tomopy/1.14/tomopy.recon.algorithm/init_tomo.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-- method: init_tomo
-  module_path: tomopy.recon.algorithm
-  parameters:
-    sinogram_order: REQUIRED
-    sharedmem: true
diff --git a/templates/tomopy/1.14/tomopy.recon.algorithm/recon.yaml b/templates/tomopy/1.14/tomopy.recon.algorithm/recon.yaml
index b1b2972e9..8b239d362 100644
--- a/templates/tomopy/1.14/tomopy.recon.algorithm/recon.yaml
+++ b/templates/tomopy/1.14/tomopy.recon.algorithm/recon.yaml
@@ -5,4 +5,4 @@
     sinogram_order: false
     algorithm: null
     init_recon: null
-    #additional parameters: AVAILABLE
+    '#additional parameters': AVAILABLE
diff --git a/templates/tomopy/1.14/tomopy.recon.rotation/mask_empty_slice.yaml b/templates/tomopy/1.14/tomopy.recon.rotation/mask_empty_slice.yaml
deleted file mode 100644
index 4d623bf61..000000000
--- a/templates/tomopy/1.14/tomopy.recon.rotation/mask_empty_slice.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-- method: mask_empty_slice
-  module_path: tomopy.recon.rotation
-  parameters:
-    threshold: 0.25
diff --git a/templates/tomopy/1.14/tomopy.sim.project/angles.yaml b/templates/tomopy/1.14/tomopy.sim.project/angles.yaml
deleted file mode 100644
index dd5e94636..000000000
--- a/templates/tomopy/1.14/tomopy.sim.project/angles.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-- method: angles
-  module_path: tomopy.sim.project
-  parameters:
-    nang: REQUIRED
-    ang1: 0.0
-    ang2: 180.0
diff --git a/templates/tomopy/1.14/tomopy.sim.project/fan_to_para.yaml b/templates/tomopy/1.14/tomopy.sim.project/fan_to_para.yaml
deleted file mode 100644
index dd7eb3fe3..000000000
--- a/templates/tomopy/1.14/tomopy.sim.project/fan_to_para.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-- method: fan_to_para
-  module_path: tomopy.sim.project
-  parameters:
-    dist: REQUIRED
-    geom: REQUIRED
diff --git a/templates/tomopy/1.14/tomopy.sim.project/para_to_fan.yaml b/templates/tomopy/1.14/tomopy.sim.project/para_to_fan.yaml
deleted file mode 100644
index 3fec2dc88..000000000
--- a/templates/tomopy/1.14/tomopy.sim.project/para_to_fan.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-- method: para_to_fan
-  module_path: tomopy.sim.project
-  parameters:
-    dist: REQUIRED
-    geom: REQUIRED
diff --git a/templates/tomopy/1.14/tomopy.sim.project/project2.yaml b/templates/tomopy/1.14/tomopy.sim.project/project2.yaml
index 562321403..9b54d232f 100644
--- a/templates/tomopy/1.14/tomopy.sim.project/project2.yaml
+++ b/templates/tomopy/1.14/tomopy.sim.project/project2.yaml
@@ -7,4 +7,4 @@
     emission: true
     pad: true
     sinogram_order: false
-    axis: 0
+    axis: auto
diff --git a/templates/tomopy/1.14/tomopy.sim.project/project3.yaml b/templates/tomopy/1.14/tomopy.sim.project/project3.yaml
index 6bc3cde1e..c1719c87f 100644
--- a/templates/tomopy/1.14/tomopy.sim.project/project3.yaml
+++ b/templates/tomopy/1.14/tomopy.sim.project/project3.yaml
@@ -8,4 +8,4 @@
     emission: true
     pad: true
     sinogram_order: false
-    axis: 0
+    axis: auto
diff --git a/templates/yaml_templates_generator.py b/templates/yaml_templates_generator.py
index 315306dfb..50996a3f8 100644
--- a/templates/yaml_templates_generator.py
+++ b/templates/yaml_templates_generator.py
@@ -29,7 +29,7 @@
 import inspect
 import os
 import re
-from typing import List, Dict
+from typing import Any, List, Dict
 
 import yaml
 
@@ -77,15 +77,15 @@ def yaml_generator(path_to_modules: str, output_folder: str) -> int:
             # put the parameters in the dictionary
             params_list: List = []
             params_dict: Dict = {}
-            for k, v in get_method_params.parameters.items():
-                if v is not None:
+            for name, value in get_method_params.parameters.items():
+                if value is not None:
                     append = True
                     for x in discard_keys:
-                        if str(k) == x:
+                        if name == x:
                             append = False
                             break
                     if append:
-                        _set_param_value(k, v, params_dict)
+                        _set_param_value(name, value, params_dict)
             method_dict = {
                 "method": method_name,
                 "module_path": module_name,
@@ -97,26 +97,33 @@ def yaml_generator(path_to_modules: str, output_folder: str) -> int:
     return 0
 
 
-def _set_param_value(k: int, v: int, params_dict: Dict):
+def _set_param_value(name: str, value: inspect.Parameter, params_dict: Dict[str, Any]):
     """Set param value for method inside dictionary
     Args:
-        k: Method name, dict key
-        v: Method value, dict value
-        params_dict: Parameter dictionary
+        name: Parameter name
+        value: Parameter value
+        params_dict: Dict containing method's parameter names and values
     """
-    if str(v).find("=") == -1 and str(k) != "kwargs":
-        params_dict[str(k)] = "REQUIRED"
-    elif str(k) == "kwargs":
+    if value.default is inspect.Parameter.empty and name != "kwargs":
+        if name in ["proj1", "proj2"]:
+            params_dict[name] = "auto"
+        else:
+            params_dict[name] = "REQUIRED"
+    elif name == "kwargs":
         params_dict["#additional parameters"] = "AVAILABLE"
-    elif str(k) == "center":
+    elif name == "axis":
+        params_dict[name] = "auto"
+    elif name == "asynchronous":
+        params_dict[name] = True
+    elif name == "center":
         # Temporary value
-        params_dict[str(k)] = "${{centering.side_outputs.centre_of_rotation}}"
-    elif str(k) == "glob_stats":
-        params_dict[str(k)] = "${{statistics.side_outputs.glob_stats}}"
-    elif str(k) == "overlap":
-        params_dict[str(k)] = "${{centering.side_outputs.overlap}}"
+        params_dict[name] = "${{centering.side_outputs.centre_of_rotation}}"
+    elif name == "glob_stats":
+        params_dict[name] = "${{statistics.side_outputs.glob_stats}}"
+    elif name == "overlap":
+        params_dict[name] = "${{centering.side_outputs.overlap}}"
     else:
-        params_dict[str(k)] = v.default
+        params_dict[name] = value.default
 
 
 def _save_yaml(module_name: str, method_name: str, params_list: List[str]):
@@ -143,7 +150,7 @@ def _set_dict_special_cases(method_dict: Dict, method_name: str):
         method_dict: Dictionary of modules and parameters
         method_name: Name of method
     """
-    if method_name in "find_center_vo":
+    if method_name in ["find_center_vo", "find_center_pc"]:
         method_dict["id"] = "centering"
         method_dict["side_outputs"] = {"cor": "centre_of_rotation"}
     if method_name in "find_center_360":
@@ -158,6 +165,7 @@ def _set_dict_special_cases(method_dict: Dict, method_name: str):
         method_dict["id"] = "statistics"
         method_dict["side_outputs"] = {"glob_stats": "glob_stats"}
 
+
 def _get_discard_data_out() -> List[str]:
     """Discard data_out from certain modules
 
@@ -195,7 +203,7 @@ def _get_discard_keys() -> List[str]:
         "out_dir",
         "angles",
         "gpu_id",
-        "comm"
+        "comm",
     ]
     return discard_keys
 


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/conftest.py b/tests/conftest.py
index e54f5fc12..d696c302b 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -8,17 +8,22 @@
 
 import pytest
 import yaml
-from httomo.runner.dataset import DataSet
+from httomo.darks_flats import DarksFlatsFileConfig
+from httomo.runner.auxiliary_data import AuxiliaryData
+from httomo.runner.dataset import DataSetBlock
 from httomo.ui_layer import _yaml_loader
 
 
 CUR_DIR = os.path.abspath(os.path.dirname(__file__))
 
+
 def pytest_configure(config):
     config.addinivalue_line("markers", "mpi: mark test to run in an MPI environment")
     config.addinivalue_line("markers", "perf: mark test as performance test")
     config.addinivalue_line("markers", "cupy: needs cupy to run")
-    config.addinivalue_line("markers", "preview: mark test to run with `httomo preview`")
+    config.addinivalue_line(
+        "markers", "preview: mark test to run with `httomo preview`"
+    )
 
 
 def pytest_addoption(parser):
@@ -73,14 +78,17 @@ def cmd():
 def standard_data():
     return "tests/test_data/tomo_standard.nxs"
 
+
 @pytest.fixture
 def data360():
     return "tests/test_data/360scan/360scan.hdf"
 
+
 @pytest.fixture(scope="session")
 def test_data_path():
     return os.path.join(CUR_DIR, "test_data")
-    
+
+
 # only load from disk once per session, and we use np.copy for the elements,
 # to ensure data in this loaded file stays as originally loaded
 @pytest.fixture(scope="session")
@@ -89,10 +97,12 @@ def data_file(test_data_path):
     # keys: data, flats, darks, angles, angles_total, detector_y, detector_x
     return np.load(in_file)
 
+
 @pytest.fixture
 @pytest.mark.cupy
 def ensure_clean_memory():
     import cupy as cp
+
     cp.get_default_memory_pool().free_all_blocks()
     cp.get_default_pinned_memory_pool().free_all_blocks()
     cache = cp.fft.config.get_plan_cache()
@@ -101,39 +111,46 @@ def ensure_clean_memory():
     cp.get_default_memory_pool().free_all_blocks()
     cp.get_default_pinned_memory_pool().free_all_blocks()
     cache = cp.fft.config.get_plan_cache()
-    cache.clear()    
+    cache.clear()
 
 
 @pytest.fixture
 def host_data(data_file):
     return np.float32(np.copy(data_file["data"]))
-    
-    
+
+
 @pytest.fixture
 @pytest.mark.cupy
 def data(host_data, ensure_clean_memory):
     import cupy as cp
+
     return cp.asarray(host_data)
 
+
 @pytest.fixture
 def host_angles(data_file):
     return np.float32(np.copy(data_file["angles"]))
 
+
 @pytest.fixture
 @pytest.mark.cupy
 def angles(host_angles, ensure_clean_memory):
     import cupy as cp
+
     return cp.asarray(host_angles)
 
+
 @pytest.fixture
 def host_angles_radians(host_angles):
     return host_angles
 
+
 @pytest.fixture
 @pytest.mark.cupy
 def angles_radians(angles):
     return angles
 
+
 @pytest.fixture
 def host_flats(data_file):
     return np.float32(np.copy(data_file["flats"]))
@@ -143,6 +160,7 @@ def host_flats(data_file):
 @pytest.mark.cupy
 def flats(host_flats, ensure_clean_memory):
     import cupy as cp
+
     return cp.asarray(host_flats)
 
 
@@ -157,6 +175,7 @@ def host_darks(
 @pytest.mark.cupy
 def darks(host_darks, ensure_clean_memory):
     import cupy as cp
+
     return cp.asarray(host_darks)
 
 
@@ -224,64 +243,101 @@ def sample_pipelines():
 def gpu_pipeline():
     return "samples/pipeline_template_examples/03_basic_gpu_pipeline_tomo_standard.yaml"
 
+
 @pytest.fixture
 def python_cpu_pipeline1():
     return "samples/python_templates/pipeline_cpu1.py"
 
+
 @pytest.fixture
 def python_cpu_pipeline2():
     return "samples/python_templates/pipeline_cpu2.py"
 
+
 @pytest.fixture
 def python_cpu_pipeline3():
     return "samples/python_templates/pipeline_cpu3.py"
 
+
 @pytest.fixture
 def python_gpu_pipeline1():
     return "samples/python_templates/pipeline_gpu1.py"
 
+
 @pytest.fixture
 def yaml_cpu_pipeline1():
     return "samples/pipeline_template_examples/pipeline_cpu1.yaml"
 
+
 @pytest.fixture
 def yaml_cpu_pipeline2():
     return "samples/pipeline_template_examples/pipeline_cpu2.yaml"
 
+
 @pytest.fixture
 def yaml_cpu_pipeline3():
     return "samples/pipeline_template_examples/pipeline_cpu3.yaml"
 
+
+@pytest.fixture
+def yaml_cpu_pipeline4():
+    return "samples/pipeline_template_examples/pipeline_cpu4.yaml"
+
+
 @pytest.fixture
 def yaml_gpu_pipeline1():
     return "samples/pipeline_template_examples/pipeline_gpu1.yaml"
 
+
 @pytest.fixture
 def yaml_gpu_pipeline360_2():
     return "samples/pipeline_template_examples/pipeline_360deg_gpu2.yaml"
 
+
 @pytest.fixture(scope="session")
 def distortion_correction_path(test_data_path):
     return os.path.join(test_data_path, "distortion-correction")
 
+
 @pytest.fixture
 def merge_yamls():
     def _merge_yamls(*yamls) -> None:
         """Merge multiple yaml files into one"""
-        data : list = []
+        data: list = []
         for y in yamls:
-            curr_yaml_list = _yaml_loader(y)[0]
+            curr_yaml_list = _yaml_loader(y)
             for x in curr_yaml_list:
                 data.append(x)
         with open("temp.yaml", "w") as file_descriptor:
             yaml.dump(data, file_descriptor)
+
     return _merge_yamls
 
+
+@pytest.fixture
+def standard_data_darks_flats_config() -> DarksFlatsFileConfig:
+    return DarksFlatsFileConfig(
+        file=Path("tests/test_data/tomo_standard.nxs"),
+        data_path="/entry1/tomo_entry/data/data",
+        image_key_path="/entry1/tomo_entry/instrument/detector/image_key",
+    )
+
+
+@pytest.fixture
+def standard_data_darks_flats_config() -> DarksFlatsFileConfig:
+    return DarksFlatsFileConfig(
+        file=Path(__file__).parent / "test_data/tomo_standard.nxs",
+        data_path="/entry1/tomo_entry/data/data",
+        image_key_path="/entry1/tomo_entry/instrument/detector/image_key",
+    )
+
+
 @pytest.fixture
-def dummy_dataset() -> DataSet:
-    return DataSet(
-        data=np.ones((10, 10, 10)),
-        angles=np.ones((20,)),
-        flats=3 * np.ones((5, 10, 10)),
-        darks=2 * np.ones((5, 10, 10)),
+def dummy_block() -> DataSetBlock:
+    data = np.ones((10, 10, 10), dtype=np.float32)
+    aux_data = AuxiliaryData(
+        angles=np.ones(data.shape[0], dtype=np.float32),
+        darks=2.0 * np.ones((2, data.shape[1], data.shape[2]), dtype=np.float32),
+        flats=1.0 * np.ones((2, data.shape[1], data.shape[2]), dtype=np.float32),
     )
+    return DataSetBlock(data=data, aux_data=aux_data)
diff --git a/tests/data/__init__.py b/tests/data/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/data/test_dataset_store.py b/tests/data/test_dataset_store.py
new file mode 100644
index 000000000..161dbeb74
--- /dev/null
+++ b/tests/data/test_dataset_store.py
@@ -0,0 +1,544 @@
+from os import PathLike
+from typing import List, Literal
+from unittest.mock import ANY
+import numpy as np
+import pytest
+from pytest_mock import MockerFixture
+from httomo.data.dataset_store import DataSetStoreReader, DataSetStoreWriter
+from mpi4py import MPI
+from httomo.runner.auxiliary_data import AuxiliaryData
+
+from httomo.runner.dataset import DataSetBlock
+from httomo.utils import make_3d_shape_from_shape
+
+
+@pytest.mark.parametrize("slicing_dim", [0, 1, 2])
+def test_writer_can_set_sizes_and_shapes_dim(tmp_path: PathLike, slicing_dim: Literal[0, 1, 2]):
+    global_shape=(30, 15, 20)
+    chunk_shape_t=list(global_shape)
+    chunk_shape_t[slicing_dim] = 5
+    chunk_shape = make_3d_shape_from_shape(chunk_shape_t)
+    global_index_t=[0, 0, 0]
+    global_index_t[slicing_dim] = 5
+    global_index = make_3d_shape_from_shape(global_index_t)
+    writer = DataSetStoreWriter(
+        slicing_dim=slicing_dim,        
+        comm=MPI.COMM_SELF,
+        temppath=tmp_path,
+    )
+    block = DataSetBlock(data=np.ones(chunk_shape, dtype=np.float32),
+                         aux_data=AuxiliaryData(angles=np.ones(global_shape[0], dtype=np.float32)),
+                         chunk_shape=chunk_shape,
+                         slicing_dim=slicing_dim,
+                         global_shape=global_shape,
+                         block_start=0,
+                         chunk_start=global_index[slicing_dim])
+    writer.write_block(block)
+
+    assert writer.global_shape == global_shape
+    assert writer.chunk_shape == chunk_shape
+    assert writer.global_index == global_index
+    assert writer.slicing_dim == slicing_dim
+
+
+def test_reader_throws_if_no_data(tmp_path: PathLike):
+    writer = DataSetStoreWriter(
+        slicing_dim=0,        
+        comm=MPI.COMM_SELF,
+        temppath=tmp_path,
+    )
+    with pytest.raises(ValueError) as e:
+        writer.make_reader()
+
+    assert "no data" in str(e)
+
+
+@pytest.mark.parametrize("file_based", [False, True])
+def test_can_write_and_read_blocks(
+    mocker: MockerFixture, tmp_path: PathLike, file_based: bool
+):
+    writer = DataSetStoreWriter(
+        slicing_dim=0,
+        comm=MPI.COMM_WORLD,
+        temppath=tmp_path,
+    )
+    
+    GLOBAL_SHAPE = (10, 10, 10)
+    global_data = np.arange(np.prod(GLOBAL_SHAPE), dtype=np.float32).reshape(
+        GLOBAL_SHAPE
+    )
+    chunk_shape = (4, GLOBAL_SHAPE[1], GLOBAL_SHAPE[2])
+    chunk_start = 3
+    block1 = DataSetBlock(
+        data=global_data[chunk_start:chunk_start+2, :, :],
+        aux_data=AuxiliaryData(angles=np.ones(GLOBAL_SHAPE[0], dtype=np.float32)),
+        global_shape=GLOBAL_SHAPE,
+        chunk_shape=chunk_shape,
+        block_start=0,
+        slicing_dim=0,
+        chunk_start=chunk_start
+    )
+    block2 = DataSetBlock(
+        data=global_data[chunk_start+2:chunk_start+2+2, :, :],
+        aux_data=AuxiliaryData(angles=np.ones(GLOBAL_SHAPE[0], dtype=np.float32)),
+        global_shape=GLOBAL_SHAPE,
+        chunk_shape=chunk_shape,
+        block_start=2,
+        slicing_dim=0,
+        chunk_start=chunk_start
+    )
+
+    if file_based:
+        mocker.patch.object(writer, "_create_numpy_data", side_effect=MemoryError)
+    writer.write_block(block1)
+    writer.write_block(block2)
+
+    reader = writer.make_reader()
+
+    rblock1 = reader.read_block(0, 2)
+    rblock2 = reader.read_block(2, 2)
+
+    assert reader.global_shape == GLOBAL_SHAPE
+    assert reader.chunk_shape == chunk_shape
+    assert reader.global_index == (chunk_start, 0, 0)
+    assert reader.slicing_dim == 0
+
+    assert isinstance(rblock1.data, np.ndarray)
+    assert isinstance(rblock2.data, np.ndarray)
+
+    np.testing.assert_array_equal(rblock1.data, block1.data)
+    np.testing.assert_array_equal(rblock2.data, block2.data)
+
+
+@pytest.mark.parametrize("file_based", [False, True])
+def test_write_after_read_throws(
+    mocker: MockerFixture, dummy_block: DataSetBlock, tmp_path: PathLike, file_based: bool
+):
+    writer = DataSetStoreWriter(
+        slicing_dim=0,
+        comm=MPI.COMM_WORLD,
+        temppath=tmp_path,
+    )
+
+    if file_based:
+        mocker.patch.object(writer, "_create_numpy_data", side_effect=MemoryError)
+    writer.write_block(dummy_block)
+
+    writer.make_reader()
+
+    with pytest.raises(ValueError):
+        writer.write_block(dummy_block)
+
+
+def test_writer_closes_file_on_finalize(
+    mocker: MockerFixture, dummy_block: DataSetBlock, tmp_path: PathLike
+):
+    writer = DataSetStoreWriter(
+        slicing_dim=0,
+        comm=MPI.COMM_WORLD,
+        temppath=tmp_path,
+    )
+
+    mocker.patch.object(writer, "_create_numpy_data", side_effect=MemoryError)
+    writer.write_block(dummy_block)
+    fileclose = mocker.patch.object(writer._h5file, "close")
+    writer.finalize()
+
+    fileclose.assert_called_once()
+
+
+def test_making_reader_closes_file_and_deletes(
+    mocker: MockerFixture, dummy_block: DataSetBlock, tmp_path: PathLike
+):
+    writer = DataSetStoreWriter(
+        slicing_dim=0,
+        comm=MPI.COMM_WORLD,
+        temppath=tmp_path,
+    )
+    mocker.patch.object(writer, "_create_numpy_data", side_effect=MemoryError)
+    writer.write_block(dummy_block)
+
+    reader = writer.make_reader()
+
+    assert writer._h5file is None
+    assert writer.filename is not None
+    assert writer.filename.exists()
+    assert isinstance(reader, DataSetStoreReader)
+    assert reader.filename == writer.filename
+    assert reader._h5file is not None
+    assert reader._h5file.get("data", None) is not None
+
+    reader.finalize()
+
+    assert not writer.filename.exists()
+
+
+def test_can_write_and_read_block_with_different_sizes(tmp_path: PathLike):
+    writer = DataSetStoreWriter(
+        slicing_dim=0,
+        comm=MPI.COMM_WORLD,
+        temppath=tmp_path,
+    )
+    GLOBAL_SHAPE = (10, 10, 10)
+    global_data = np.arange(np.prod(GLOBAL_SHAPE), dtype=np.float32).reshape(
+        GLOBAL_SHAPE
+    )
+    chunk_shape = (4, GLOBAL_SHAPE[1], GLOBAL_SHAPE[2])
+    chunk_start = 3
+    block1 = DataSetBlock(
+        data=global_data[chunk_start:chunk_start+2, :, :],
+        aux_data=AuxiliaryData(angles=np.ones(GLOBAL_SHAPE[0], dtype=np.float32)),
+        global_shape=GLOBAL_SHAPE,
+        chunk_shape=chunk_shape,
+        block_start=0,
+        slicing_dim=0,
+        chunk_start=chunk_start
+    )
+    block2 = DataSetBlock(
+        data=global_data[chunk_start+2:chunk_start+2+2, :, :],
+        aux_data=AuxiliaryData(angles=np.ones(GLOBAL_SHAPE[0], dtype=np.float32)),
+        global_shape=GLOBAL_SHAPE,
+        chunk_shape=chunk_shape,
+        block_start=2,
+        slicing_dim=0,
+        chunk_start=chunk_start
+    )
+
+    writer.write_block(block1)
+    writer.write_block(block2)
+
+    reader = writer.make_reader()
+
+    rblock = reader.read_block(0, 4)
+
+    np.testing.assert_array_equal(rblock.data, global_data[chunk_start:chunk_start+4, :, :])
+
+
+def test_writing_inconsistent_global_shapes_fails(tmp_path: PathLike):
+    writer = DataSetStoreWriter(
+        slicing_dim=0,
+        comm=MPI.COMM_WORLD,
+        temppath=tmp_path,
+    )
+    GLOBAL_SHAPE = (10, 10, 10)
+    global_data = np.arange(np.prod(GLOBAL_SHAPE), dtype=np.float32).reshape(
+        GLOBAL_SHAPE
+    )
+    chunk_shape = (4, GLOBAL_SHAPE[1], GLOBAL_SHAPE[2])
+    chunk_start = 3
+    aux_data=AuxiliaryData(angles=np.ones(GLOBAL_SHAPE[0]+10, dtype=np.float32))
+    block1 = DataSetBlock(
+        data=global_data[:2, :, :],
+        aux_data=aux_data,
+        global_shape=GLOBAL_SHAPE,
+        chunk_shape=chunk_shape,
+        block_start=0,
+        slicing_dim=0,
+        chunk_start=chunk_start
+    )
+    block2 = DataSetBlock(
+        data=global_data[:2, :, :],
+        aux_data=aux_data,
+        global_shape=(GLOBAL_SHAPE[0]+1, GLOBAL_SHAPE[1], GLOBAL_SHAPE[2]),
+        chunk_shape=chunk_shape,
+        block_start=2,
+        slicing_dim=0,
+        chunk_start=chunk_start
+    )
+
+    writer.write_block(block1)
+    with pytest.raises(ValueError) as e:
+        writer.write_block(block2)
+
+    assert "inconsistent shape" in str(e)
+
+def test_writing_inconsistent_chunk_shapes_fails(tmp_path: PathLike):
+    writer = DataSetStoreWriter(
+        slicing_dim=0,
+        comm=MPI.COMM_WORLD,
+        temppath=tmp_path,
+    )
+    GLOBAL_SHAPE = (10, 10, 10)
+    global_data = np.arange(np.prod(GLOBAL_SHAPE), dtype=np.float32).reshape(
+        GLOBAL_SHAPE
+    )
+    chunk_shape = (4, GLOBAL_SHAPE[1], GLOBAL_SHAPE[2])
+    chunk_start = 3
+    block1 = DataSetBlock(
+        data=global_data[:2, :, :],
+        aux_data=AuxiliaryData(angles=np.ones(GLOBAL_SHAPE[0], dtype=np.float32)),
+        global_shape=GLOBAL_SHAPE,
+        chunk_shape=chunk_shape,
+        block_start=0,
+        slicing_dim=0,
+        chunk_start=chunk_start
+    )
+    block2 = DataSetBlock(
+        data=global_data[2:2, :, :],
+        aux_data=AuxiliaryData(angles=np.ones(GLOBAL_SHAPE[0], dtype=np.float32)),
+        global_shape=GLOBAL_SHAPE,
+        chunk_shape=(chunk_shape[0]-1, chunk_shape[1], chunk_shape[2]),
+        block_start=2,
+        slicing_dim=0,
+        chunk_start=chunk_start
+    )
+
+    writer.write_block(block1)
+    with pytest.raises(ValueError) as e:
+        writer.write_block(block2)
+
+    assert "inconsistent shape" in str(e)
+
+def test_writing_inconsistent_global_index_fails(tmp_path: PathLike):
+    writer = DataSetStoreWriter(
+        slicing_dim=0,
+        comm=MPI.COMM_WORLD,
+        temppath=tmp_path,
+    )
+    GLOBAL_SHAPE = (10, 10, 10)
+    global_data = np.arange(np.prod(GLOBAL_SHAPE), dtype=np.float32).reshape(
+        GLOBAL_SHAPE
+    )
+    chunk_shape = (4, GLOBAL_SHAPE[1], GLOBAL_SHAPE[2])
+    chunk_start = 3
+    block1 = DataSetBlock(
+        data=global_data[:2, :, :],
+        aux_data=AuxiliaryData(angles=np.ones(GLOBAL_SHAPE[0], dtype=np.float32)),
+        global_shape=GLOBAL_SHAPE,
+        chunk_shape=chunk_shape,
+        block_start=0,
+        slicing_dim=0,
+        chunk_start=chunk_start
+    )
+    block2 = DataSetBlock(
+        data=global_data[2:2, :, :],
+        aux_data=AuxiliaryData(angles=np.ones(GLOBAL_SHAPE[0], dtype=np.float32)),
+        global_shape=GLOBAL_SHAPE,
+        chunk_shape=chunk_shape,
+        block_start=2,
+        slicing_dim=0,
+        chunk_start=chunk_start+2
+    )
+
+    writer.write_block(block1)
+    with pytest.raises(ValueError) as e:
+        writer.write_block(block2)
+
+    assert "inconsistent shape" in str(e)
+
+
+
+def test_create_new_data_goes_to_file_on_memory_error(
+    mocker: MockerFixture, dummy_block: DataSetBlock, tmp_path: PathLike
+):
+    writer = DataSetStoreWriter(
+        slicing_dim=0,
+        comm=MPI.COMM_WORLD,
+        temppath=tmp_path,
+    )
+
+    mocker.patch.object(writer, "_create_numpy_data", side_effect=MemoryError)
+    createh5_mock = mocker.patch.object(
+        writer, "_create_h5_data", return_value=dummy_block.data
+    )
+
+    writer.write_block(dummy_block)
+
+    createh5_mock.assert_called_with(
+        writer.global_shape,
+        dummy_block.data.dtype,
+        ANY,
+        writer.comm,
+    )
+    
+    
+def test_create_new_data_goes_to_file_on_memory_limit(
+    mocker: MockerFixture, tmp_path: PathLike
+):
+    GLOBAL_SHAPE = (500, 10, 10)
+    data = np.ones(GLOBAL_SHAPE, dtype=np.float32)
+    aux_data = AuxiliaryData(
+        angles=np.ones(data.shape[0], dtype=np.float32),
+        darks=2.*np.ones((2, GLOBAL_SHAPE[1], GLOBAL_SHAPE[2]), dtype=np.float32),
+        flats=1.*np.ones((2, GLOBAL_SHAPE[1], GLOBAL_SHAPE[2]), dtype=np.float32),
+    )
+    block = DataSetBlock(
+        data=data[0:2, :, :],
+        aux_data=aux_data,
+        slicing_dim=0,
+        block_start=0,
+        chunk_start=0,
+        global_shape=GLOBAL_SHAPE,
+        chunk_shape=GLOBAL_SHAPE,
+    )
+    writer = DataSetStoreWriter(
+        slicing_dim=0,
+        comm=MPI.COMM_WORLD,
+        temppath=tmp_path,
+        memory_limit_bytes=block.data.nbytes + 5  # only one block will fit in memory
+    )
+
+    createh5_mock = mocker.patch.object(
+        writer, "_create_h5_data", return_value=block.data
+    )
+
+    writer.write_block(block)
+
+    createh5_mock.assert_called_with(
+        writer.global_shape,
+        block.data.dtype,
+        ANY,
+        writer.comm,
+    )
+
+
+def test_calls_reslice(
+    mocker: MockerFixture, dummy_block: DataSetBlock, tmp_path: PathLike
+):
+    writer = DataSetStoreWriter(
+        slicing_dim=0,
+        comm=MPI.COMM_WORLD,
+        temppath=tmp_path,
+    )
+
+    writer.write_block(dummy_block)
+
+    reslice_mock = mocker.patch.object(DataSetStoreReader, "_reslice")
+    d = writer._data
+    writer.make_reader(new_slicing_dim=1)
+
+    reslice_mock.assert_called_with(0, 1, d)
+
+
+@pytest.mark.parametrize("file_based", [False, True])
+def test_reslice_single_block_single_process(
+    mocker: MockerFixture, dummy_block: DataSetBlock, tmp_path: PathLike, file_based: bool
+):
+    writer = DataSetStoreWriter(
+        slicing_dim=0,
+        comm=MPI.COMM_WORLD,
+        temppath=tmp_path,
+    )
+    if file_based:
+        mocker.patch.object(writer, "_create_numpy_data", side_effect=MemoryError)
+
+    writer.write_block(dummy_block)
+
+    assert writer.is_file_based is file_based
+
+    reader = writer.make_reader(new_slicing_dim=1)
+
+    block = reader.read_block(1, 2)
+
+    assert reader.slicing_dim == 1
+    assert reader.global_shape == writer.global_shape
+    assert reader.chunk_shape == writer.chunk_shape
+    assert reader.global_index == (0, 0, 0)
+
+    assert block.global_shape == reader.global_shape
+    assert block.shape == (reader.global_shape[0], 2, reader.global_shape[2])
+    assert block.chunk_index == (0, 1, 0)
+    assert block.chunk_shape == reader.chunk_shape
+
+    assert reader.is_file_based is file_based
+
+    np.testing.assert_array_equal(block.data, dummy_block.data[:, 1:3, :])
+    np.testing.assert_array_equal(block.flats, dummy_block.flats)
+    np.testing.assert_array_equal(block.darks, dummy_block.darks)
+    np.testing.assert_array_equal(block.angles, dummy_block.angles)
+
+
+@pytest.mark.mpi
+@pytest.mark.skipif(
+    MPI.COMM_WORLD.size != 2, reason="Only rank-2 MPI is supported with this test"
+)
+@pytest.mark.parametrize(
+    "out_of_memory_ranks",
+    [[], [1], [0, 1]],
+    ids=[
+        "out_of_memory=none",
+        "out_of_memory=one process",
+        "out_of_memory=both processes",
+    ],
+)
+def test_full_integration_with_reslice(
+    mocker: MockerFixture,
+    tmp_path: PathLike,
+    out_of_memory_ranks: List[int],
+):
+    ########### ARRANGE DATA and mocks
+
+    GLOBAL_DATA_SHAPE = (10, 10, 10)
+    global_data = np.arange(np.prod(GLOBAL_DATA_SHAPE), dtype=np.float32).reshape(
+        GLOBAL_DATA_SHAPE
+    )
+    angles = np.ones(GLOBAL_DATA_SHAPE[0], dtype=np.float32)
+    flats = 3 * np.ones((5, 10, 10), dtype=np.float32)
+    darks = 2 * np.ones((5, 10, 10), dtype=np.float32)
+    aux_data = AuxiliaryData(angles=angles, flats=flats, darks=darks)
+    comm = MPI.COMM_WORLD
+    assert comm.size == 2
+
+    # start idx and size in slicing dim 0 for the writer
+    chunk_start = 0 if comm.rank == 0 else GLOBAL_DATA_SHAPE[0] // 2
+    chunk_size = GLOBAL_DATA_SHAPE[0] // 2
+
+    block = DataSetBlock(
+        data=global_data[chunk_start : chunk_start + chunk_size, :, :],
+        aux_data=aux_data,
+        global_shape=GLOBAL_DATA_SHAPE,
+        block_start=0,
+        chunk_start=chunk_start,
+        chunk_shape=(chunk_size, GLOBAL_DATA_SHAPE[1], GLOBAL_DATA_SHAPE[2])
+    )
+
+    writer = DataSetStoreWriter(
+        slicing_dim=0,
+        comm=comm,
+        temppath=tmp_path,
+    )
+
+    if comm.rank in out_of_memory_ranks:
+        # make it throw an exception, so it reverts to file-based store
+        mocker.patch.object(writer, "_create_numpy_data", side_effect=MemoryError)
+
+    ############# ACT
+
+    # write full chunk-sized block
+    writer.write_block(block)
+    reader = writer.make_reader(new_slicing_dim=1)
+    # read a smaller block, starting at index 1
+    block = reader.read_block(1, 2)
+
+    ############ ASSERT
+
+    assert reader.slicing_dim == 1
+    assert reader.global_shape == GLOBAL_DATA_SHAPE
+    assert reader.global_shape == writer.global_shape
+    assert reader.chunk_shape == (
+        GLOBAL_DATA_SHAPE[0],
+        GLOBAL_DATA_SHAPE[1] // 2,
+        GLOBAL_DATA_SHAPE[2],
+    )
+    assert block.global_shape == reader.global_shape
+    assert block.shape == (GLOBAL_DATA_SHAPE[0], 2, GLOBAL_DATA_SHAPE[2])
+    assert block.chunk_index == (0, 1, 0)
+    assert block.chunk_shape == reader.chunk_shape
+
+    np.testing.assert_array_equal(block.flats, flats)
+    np.testing.assert_array_equal(block.darks, darks)
+    np.testing.assert_array_equal(block.angles, angles)
+
+    if comm.rank == 0:
+        assert writer.global_index == (0, 0, 0)
+        assert reader.global_index == (0, 0, 0)
+        np.testing.assert_array_equal(block.data, global_data[:, 1:3, :])
+    elif comm.rank == 1:
+        assert writer.global_index == (GLOBAL_DATA_SHAPE[0] // 2, 0, 0)
+        assert reader.global_index == (0, GLOBAL_DATA_SHAPE[1] // 2, 0)
+        np.testing.assert_array_equal(
+            block.data,
+            global_data[
+                :, GLOBAL_DATA_SHAPE[1] // 2 + 1 : GLOBAL_DATA_SHAPE[1] // 2 + 3, :
+            ],
+        )
diff --git a/tests/loaders/test_standard_tomo_loader.py b/tests/loaders/test_standard_tomo_loader.py
new file mode 100644
index 000000000..20edc1d4d
--- /dev/null
+++ b/tests/loaders/test_standard_tomo_loader.py
@@ -0,0 +1,726 @@
+from pathlib import Path
+from typing import Tuple
+from unittest import mock
+
+import h5py
+import pytest
+import numpy as np
+from mpi4py import MPI
+from pytest_mock import MockerFixture
+
+from httomo.darks_flats import DarksFlatsFileConfig
+from httomo.loaders.standard_tomo_loader import StandardTomoLoader
+from httomo.loaders.types import RawAngles, UserDefinedAngles
+from httomo.preview import PreviewConfig, PreviewDimConfig
+
+
+def make_standard_tomo_loader() -> StandardTomoLoader:
+    """
+    Create an instance of `StandardTomoLoader` with some commonly used default values for
+    loading the test data `tomo_standard.nxs`.
+    """
+    IN_FILE_PATH = Path(__file__).parent.parent / "test_data/tomo_standard.nxs"
+    DARKS_FLATS_CONFIG = DarksFlatsFileConfig(
+        file=IN_FILE_PATH,
+        data_path="/entry1/tomo_entry/data/data",
+        image_key_path="/entry1/tomo_entry/instrument/detector/image_key",
+    )
+    ANGLES_CONFIG = RawAngles(data_path="/entry1/tomo_entry/data/rotation_angle")
+    PREVIEW_CONFIG = PreviewConfig(
+        angles=PreviewDimConfig(start=0, stop=180),
+        detector_y=PreviewDimConfig(start=0, stop=128),
+        detector_x=PreviewDimConfig(start=0, stop=160),
+    )
+    SLICING_DIM = 0
+    COMM = MPI.COMM_WORLD
+    loader = StandardTomoLoader(
+        in_file=IN_FILE_PATH,
+        data_path=DARKS_FLATS_CONFIG.data_path,
+        image_key_path=DARKS_FLATS_CONFIG.image_key_path,
+        darks=DARKS_FLATS_CONFIG,
+        flats=DARKS_FLATS_CONFIG,
+        angles=ANGLES_CONFIG,
+        preview_config=PREVIEW_CONFIG,
+        slicing_dim=SLICING_DIM,
+        comm=COMM,
+    )
+    return loader
+
+
+def test_standard_tomo_loader_gives_h5py_dataset():
+    with mock.patch(
+        "httomo.darks_flats.get_darks_flats",
+        return_value=(np.zeros(1), np.zeros(1)),
+    ):
+        loader = make_standard_tomo_loader()
+
+    assert isinstance(loader._data, h5py.Dataset)
+
+
+def test_standard_tomo_loader_get_slicing_dim():
+    with mock.patch(
+        "httomo.darks_flats.get_darks_flats",
+        return_value=(np.zeros(1), np.zeros(1)),
+    ):
+        loader = make_standard_tomo_loader()
+    assert loader.slicing_dim == 0
+
+
+def test_standard_tomo_loader_get_chunk_index_single_proc():
+    with mock.patch(
+        "httomo.darks_flats.get_darks_flats",
+        return_value=(np.zeros(1), np.zeros(1)),
+    ):
+        loader = make_standard_tomo_loader()
+    CHUNK_INDEX = (0, 0, 0)
+    assert loader.global_index == CHUNK_INDEX
+
+
+@pytest.mark.mpi
+@pytest.mark.skipif(
+    MPI.COMM_WORLD.size != 2, reason="Only rank-2 MPI is supported with this test"
+)
+@pytest.mark.parametrize(
+    "preview_config",
+    [
+        PreviewConfig(
+            angles=PreviewDimConfig(start=0, stop=180),
+            detector_y=PreviewDimConfig(start=0, stop=128),
+            detector_x=PreviewDimConfig(start=0, stop=160),
+        ),
+        PreviewConfig(
+            angles=PreviewDimConfig(start=0, stop=180),
+            detector_y=PreviewDimConfig(start=5, stop=128),
+            detector_x=PreviewDimConfig(start=0, stop=160),
+        ),
+        PreviewConfig(
+            angles=PreviewDimConfig(start=0, stop=180),
+            detector_y=PreviewDimConfig(start=0, stop=128),
+            detector_x=PreviewDimConfig(start=5, stop=160),
+        ),
+    ],
+    ids=["no_cropping", "crop_det_y", "crop_det_x"],
+)
+def test_standard_tomo_loader_previewed_get_chunk_index_two_procs(
+    standard_data_path: str,
+    standard_image_key_path: str,
+    preview_config: PreviewConfig,
+):
+    DATA_SHAPE = (180, 128, 160)
+    IN_FILE_PATH = Path(__file__).parent.parent / "test_data/tomo_standard.nxs"
+    DARKS_FLATS_CONFIG = DarksFlatsFileConfig(
+        file=IN_FILE_PATH,
+        data_path=standard_data_path,
+        image_key_path=standard_image_key_path,
+    )
+    ANGLES_CONFIG = RawAngles(data_path="/entry1/tomo_entry/data/rotation_angle")
+    SLICING_DIM = 0
+    COMM = MPI.COMM_WORLD
+
+    chunk_index = (0, 0, 0) if COMM.rank == 0 else (DATA_SHAPE[0] // 2, 0, 0)
+
+    with mock.patch(
+        "httomo.darks_flats.get_darks_flats",
+        return_value=(np.zeros(1), np.zeros(1)),
+    ):
+        loader = StandardTomoLoader(
+            in_file=IN_FILE_PATH,
+            data_path=DARKS_FLATS_CONFIG.data_path,
+            image_key_path=DARKS_FLATS_CONFIG.image_key_path,
+            darks=DARKS_FLATS_CONFIG,
+            flats=DARKS_FLATS_CONFIG,
+            angles=ANGLES_CONFIG,
+            preview_config=preview_config,
+            slicing_dim=SLICING_DIM,
+            comm=COMM,
+        )
+
+    assert loader.global_index == chunk_index
+
+
+@pytest.mark.parametrize(
+    "preview_config, expected_chunk_shape",
+    [
+        (
+            PreviewConfig(
+                angles=PreviewDimConfig(start=0, stop=180),
+                detector_y=PreviewDimConfig(start=0, stop=128),
+                detector_x=PreviewDimConfig(start=0, stop=160),
+            ),
+            (180, 128, 160),
+        ),
+        (
+            PreviewConfig(
+                angles=PreviewDimConfig(start=0, stop=180),
+                detector_y=PreviewDimConfig(start=5, stop=15),
+                detector_x=PreviewDimConfig(start=0, stop=160),
+            ),
+            (180, 10, 160),
+        ),
+        (
+            PreviewConfig(
+                angles=PreviewDimConfig(start=0, stop=180),
+                detector_y=PreviewDimConfig(start=0, stop=128),
+                detector_x=PreviewDimConfig(start=5, stop=15),
+            ),
+            (180, 128, 10),
+        ),
+    ],
+    ids=["no_cropping", "crop_det_y", "crop_det_x"],
+)
+def test_standard_tomo_loader_get_chunk_shape_single_proc(
+    standard_data_path: str,
+    standard_image_key_path: str,
+    preview_config: PreviewConfig,
+    expected_chunk_shape: Tuple[int, int, int],
+):
+    IN_FILE_PATH = Path(__file__).parent.parent / "test_data/tomo_standard.nxs"
+    DARKS_FLATS_CONFIG = DarksFlatsFileConfig(
+        file=IN_FILE_PATH,
+        data_path=standard_data_path,
+        image_key_path=standard_image_key_path,
+    )
+    ANGLES_CONFIG = RawAngles(data_path="/entry1/tomo_entry/data/rotation_angle")
+    SLICING_DIM = 0
+    COMM = MPI.COMM_WORLD
+
+    with mock.patch(
+        "httomo.darks_flats.get_darks_flats",
+        return_value=(np.zeros(1), np.zeros(1)),
+    ):
+        loader = StandardTomoLoader(
+            in_file=IN_FILE_PATH,
+            data_path=DARKS_FLATS_CONFIG.data_path,
+            image_key_path=DARKS_FLATS_CONFIG.image_key_path,
+            darks=DARKS_FLATS_CONFIG,
+            flats=DARKS_FLATS_CONFIG,
+            angles=ANGLES_CONFIG,
+            preview_config=preview_config,
+            slicing_dim=SLICING_DIM,
+            comm=COMM,
+        )
+
+    assert loader.chunk_shape == expected_chunk_shape
+
+
+@pytest.mark.mpi
+@pytest.mark.skipif(
+    MPI.COMM_WORLD.size != 2, reason="Only rank-2 MPI is supported with this test"
+)
+@pytest.mark.parametrize(
+    "preview_config, expected_chunk_shape",
+    [
+        (
+            PreviewConfig(
+                angles=PreviewDimConfig(start=0, stop=180),
+                detector_y=PreviewDimConfig(start=0, stop=128),
+                detector_x=PreviewDimConfig(start=0, stop=160),
+            ),
+            (90, 128, 160),
+        ),
+        (
+            PreviewConfig(
+                angles=PreviewDimConfig(start=0, stop=180),
+                detector_y=PreviewDimConfig(start=5, stop=15),
+                detector_x=PreviewDimConfig(start=0, stop=160),
+            ),
+            (90, 10, 160),
+        ),
+        (
+            PreviewConfig(
+                angles=PreviewDimConfig(start=0, stop=180),
+                detector_y=PreviewDimConfig(start=0, stop=128),
+                detector_x=PreviewDimConfig(start=5, stop=15),
+            ),
+            (90, 128, 10),
+        ),
+    ],
+    ids=["no_cropping", "crop_det_y", "crop_det_x"],
+)
+def test_standard_tomo_loader_get_chunk_shape_two_procs(
+    standard_data_path: str,
+    standard_image_key_path: str,
+    preview_config: PreviewConfig,
+    expected_chunk_shape: Tuple[int, int, int],
+):
+    IN_FILE_PATH = Path(__file__).parent.parent / "test_data/tomo_standard.nxs"
+    DARKS_FLATS_CONFIG = DarksFlatsFileConfig(
+        file=IN_FILE_PATH,
+        data_path=standard_data_path,
+        image_key_path=standard_image_key_path,
+    )
+    ANGLES_CONFIG = RawAngles(data_path="/entry1/tomo_entry/data/rotation_angle")
+    SLICING_DIM = 0
+    COMM = MPI.COMM_WORLD
+
+    with mock.patch(
+        "httomo.darks_flats.get_darks_flats",
+        return_value=(np.zeros(1), np.zeros(1)),
+    ):
+        loader = StandardTomoLoader(
+            in_file=IN_FILE_PATH,
+            data_path=DARKS_FLATS_CONFIG.data_path,
+            image_key_path=DARKS_FLATS_CONFIG.image_key_path,
+            darks=DARKS_FLATS_CONFIG,
+            flats=DARKS_FLATS_CONFIG,
+            angles=ANGLES_CONFIG,
+            preview_config=preview_config,
+            slicing_dim=SLICING_DIM,
+            comm=COMM,
+        )
+
+    assert loader.chunk_shape == expected_chunk_shape
+
+
+@pytest.mark.parametrize(
+    "preview_config",
+    [
+        PreviewConfig(
+            angles=PreviewDimConfig(start=0, stop=180),
+            detector_y=PreviewDimConfig(start=0, stop=128),
+            detector_x=PreviewDimConfig(start=0, stop=160),
+        ),
+        PreviewConfig(
+            angles=PreviewDimConfig(start=0, stop=180),
+            detector_y=PreviewDimConfig(start=0, stop=10),
+            detector_x=PreviewDimConfig(start=0, stop=160),
+        ),
+        PreviewConfig(
+            angles=PreviewDimConfig(start=0, stop=180),
+            detector_y=PreviewDimConfig(start=10, stop=20),
+            detector_x=PreviewDimConfig(start=0, stop=160),
+        ),
+        PreviewConfig(
+            angles=PreviewDimConfig(start=0, stop=180),
+            detector_y=PreviewDimConfig(start=0, stop=128),
+            detector_x=PreviewDimConfig(start=0, stop=10),
+        ),
+        PreviewConfig(
+            angles=PreviewDimConfig(start=0, stop=180),
+            detector_y=PreviewDimConfig(start=0, stop=128),
+            detector_x=PreviewDimConfig(start=10, stop=20),
+        ),
+    ],
+    ids=[
+        "no_cropping",
+        "crop_det_y_start_0",
+        "crop_det_y_start_10",
+        "crop_det_x_start_0",
+        "crop_det_x_start_10",
+    ],
+)
+def test_standard_tomo_loader_read_block_single_proc(
+    standard_data_path: str,
+    standard_image_key_path: str,
+    preview_config: PreviewConfig,
+):
+    IN_FILE_PATH = Path(__file__).parent.parent / "test_data/tomo_standard.nxs"
+    DARKS_FLATS_CONFIG = DarksFlatsFileConfig(
+        file=IN_FILE_PATH,
+        data_path=standard_data_path,
+        image_key_path=standard_image_key_path,
+    )
+    ANGLES_CONFIG = RawAngles(data_path="/entry1/tomo_entry/data/rotation_angle")
+    SLICING_DIM = 0
+    COMM = MPI.COMM_WORLD
+
+    BLOCK_START = 2
+    BLOCK_LENGTH = 4
+    PROJS_START = 0
+    expected_block_shape = (
+        BLOCK_LENGTH,
+        preview_config.detector_y.stop - preview_config.detector_y.start,
+        preview_config.detector_x.stop - preview_config.detector_x.start,
+    )
+    # Index of block relative to the chunk it belongs to
+    EXPECTED_CHUNK_INDEX = (BLOCK_START, 0, 0)
+    # Index of block relative to the global data it belongs to (ie, includes chunk shift - for
+    # single proc, this is the same as the expected chunk index)
+    EXPECTED_BLOCK_GLOBAL_INDEX = (BLOCK_START, 0, 0)
+
+    with mock.patch(
+        "httomo.darks_flats.get_darks_flats",
+        return_value=(np.zeros(1), np.zeros(1)),
+    ):
+        loader = StandardTomoLoader(
+            in_file=IN_FILE_PATH,
+            data_path=DARKS_FLATS_CONFIG.data_path,
+            image_key_path=DARKS_FLATS_CONFIG.image_key_path,
+            darks=DARKS_FLATS_CONFIG,
+            flats=DARKS_FLATS_CONFIG,
+            angles=ANGLES_CONFIG,
+            preview_config=preview_config,
+            slicing_dim=SLICING_DIM,
+            comm=COMM,
+        )
+
+    block = loader.read_block(BLOCK_START, BLOCK_LENGTH)
+
+    with h5py.File(IN_FILE_PATH, "r") as f:
+        dataset: h5py.Dataset = f[standard_data_path]
+        projs: np.ndarray = dataset[
+            PROJS_START + BLOCK_START : PROJS_START + BLOCK_START + BLOCK_LENGTH,
+            preview_config.detector_y.start : preview_config.detector_y.stop,
+            preview_config.detector_x.start : preview_config.detector_x.stop,
+        ]
+
+    assert block.global_index == EXPECTED_BLOCK_GLOBAL_INDEX
+    assert block.chunk_index == EXPECTED_CHUNK_INDEX
+    assert block.data.shape == expected_block_shape
+    np.testing.assert_array_equal(block.data, projs)
+
+
+@pytest.mark.mpi
+@pytest.mark.skipif(
+    MPI.COMM_WORLD.size != 2, reason="Only rank-2 MPI is supported with this test"
+)
+@pytest.mark.parametrize(
+    "preview_config",
+    [
+        PreviewConfig(
+            angles=PreviewDimConfig(start=0, stop=180),
+            detector_y=PreviewDimConfig(start=0, stop=128),
+            detector_x=PreviewDimConfig(start=0, stop=160),
+        ),
+        PreviewConfig(
+            angles=PreviewDimConfig(start=0, stop=180),
+            detector_y=PreviewDimConfig(start=0, stop=10),
+            detector_x=PreviewDimConfig(start=0, stop=160),
+        ),
+        PreviewConfig(
+            angles=PreviewDimConfig(start=0, stop=180),
+            detector_y=PreviewDimConfig(start=10, stop=20),
+            detector_x=PreviewDimConfig(start=0, stop=160),
+        ),
+        PreviewConfig(
+            angles=PreviewDimConfig(start=0, stop=180),
+            detector_y=PreviewDimConfig(start=0, stop=128),
+            detector_x=PreviewDimConfig(start=0, stop=10),
+        ),
+        PreviewConfig(
+            angles=PreviewDimConfig(start=0, stop=180),
+            detector_y=PreviewDimConfig(start=0, stop=128),
+            detector_x=PreviewDimConfig(start=10, stop=20),
+        ),
+    ],
+    ids=[
+        "no_cropping",
+        "crop_det_y_start_0",
+        "crop_det_y_start_10",
+        "crop_det_x_start_0",
+        "crop_det_x_start_10",
+    ],
+)
+def test_standard_tomo_loader_read_block_two_procs(
+    standard_data_path: str,
+    standard_image_key_path: str,
+    preview_config: PreviewConfig,
+):
+    IN_FILE_PATH = Path(__file__).parent.parent / "test_data/tomo_standard.nxs"
+    DARKS_FLATS_CONFIG = DarksFlatsFileConfig(
+        file=IN_FILE_PATH,
+        data_path=standard_data_path,
+        image_key_path=standard_image_key_path,
+    )
+    ANGLES_CONFIG = RawAngles(data_path="/entry1/tomo_entry/data/rotation_angle")
+    SLICING_DIM = 0
+    COMM = MPI.COMM_WORLD
+
+    BLOCK_START = 2
+    BLOCK_LENGTH = 4
+    expected_block_shape = (
+        BLOCK_LENGTH,
+        preview_config.detector_y.stop - preview_config.detector_y.start,
+        preview_config.detector_x.stop - preview_config.detector_x.start,
+    )
+    DATA_SHAPE = (180, 128, 160)
+
+    with mock.patch(
+        "httomo.darks_flats.get_darks_flats",
+        return_value=(np.zeros(1), np.zeros(1)),
+    ):
+        loader = StandardTomoLoader(
+            in_file=IN_FILE_PATH,
+            data_path=DARKS_FLATS_CONFIG.data_path,
+            image_key_path=DARKS_FLATS_CONFIG.image_key_path,
+            darks=DARKS_FLATS_CONFIG,
+            flats=DARKS_FLATS_CONFIG,
+            angles=ANGLES_CONFIG,
+            preview_config=preview_config,
+            slicing_dim=SLICING_DIM,
+            comm=COMM,
+        )
+
+    block = loader.read_block(BLOCK_START, BLOCK_LENGTH)
+
+    projs_start = (
+        preview_config.angles.start
+        if COMM.rank == 0
+        else (preview_config.angles.stop - preview_config.angles.start) // 2
+    )
+    # Index of block relative to the chunk it belongs to
+    expected_chunk_index = (BLOCK_START, 0, 0)
+    # Index of block relative to the global data it belongs to (ie, includes chunk shift - this
+    # will differ across two procs)
+    expected_block_global_index = (
+        (BLOCK_START, 0, 0)
+        if COMM.rank == 0
+        else (DATA_SHAPE[0] // 2 + BLOCK_START, 0, 0)
+    )
+
+    with h5py.File(IN_FILE_PATH, "r") as f:
+        dataset: h5py.Dataset = f[standard_data_path]
+        projs: np.ndarray = dataset[
+            projs_start + BLOCK_START : projs_start + BLOCK_START + BLOCK_LENGTH,
+            preview_config.detector_y.start : preview_config.detector_y.stop,
+            preview_config.detector_x.start : preview_config.detector_x.stop,
+        ]
+
+    assert block.global_index == expected_block_global_index
+    assert block.chunk_index == expected_chunk_index
+    assert block.data.shape == expected_block_shape
+    np.testing.assert_array_equal(block.data, projs)
+
+
+def test_standard_tomo_loader_read_block_adjust_for_darks_flats_single_proc():
+    IN_FILE_PATH = Path(__file__).parent.parent / "test_data/k11_diad/k11-18014.nxs"
+    DATA_PATH = "/entry/imaging/data"
+    IMAGE_KEY_PATH = "/entry/instrument/imaging/image_key"
+    ANGLES_CONFIG = RawAngles(data_path="/entry/imaging_sum/gts_theta_value")
+    DARKS_CONFIG = FLATS_CONFIG = DarksFlatsFileConfig(
+        file=IN_FILE_PATH,
+        data_path=DATA_PATH,
+        image_key_path=IMAGE_KEY_PATH,
+    )
+    PREVIEW_CONFIG = PreviewConfig(
+        angles=PreviewDimConfig(start=0, stop=3201),
+        detector_y=PreviewDimConfig(start=0, stop=22),
+        detector_x=PreviewDimConfig(start=0, stop=26),
+    )
+    SLICING_DIM = 0
+    COMM = MPI.COMM_WORLD
+
+    with mock.patch(
+        "httomo.darks_flats.get_darks_flats",
+        return_value=(np.zeros(1), np.zeros(1)),
+    ):
+        loader = StandardTomoLoader(
+            in_file=IN_FILE_PATH,
+            data_path=DATA_PATH,
+            image_key_path=IMAGE_KEY_PATH,
+            darks=DARKS_CONFIG,
+            flats=FLATS_CONFIG,
+            angles=ANGLES_CONFIG,
+            preview_config=PREVIEW_CONFIG,
+            slicing_dim=SLICING_DIM,
+            comm=COMM,
+        )
+
+    BLOCK_START = 0
+    BLOCK_LENGTH = 4
+    EXPECTED_BLOCK_GLOBAL_INDEX = (BLOCK_START, 0, 0)
+    block = loader.read_block(BLOCK_START, BLOCK_LENGTH)
+
+    # Darks/flats are at indices 0 to 99 (and 3101 to 3200), projection data starts at index
+    # 100
+    PROJS_START = 100
+    # Index of block relative to the chunk it belongs to
+    expected_chunk_index = (BLOCK_START, 0, 0)
+
+    with h5py.File(IN_FILE_PATH, "r") as f:
+        dataset: h5py.Dataset = f[DATA_PATH]
+        projs: np.ndarray = dataset[
+            PROJS_START + BLOCK_START : PROJS_START + BLOCK_START + BLOCK_LENGTH
+        ]
+
+    assert block.global_index == EXPECTED_BLOCK_GLOBAL_INDEX
+    assert block.chunk_index == expected_chunk_index
+    assert block.data.shape[SLICING_DIM] == BLOCK_LENGTH
+    np.testing.assert_array_equal(block.data, projs)
+
+
+@pytest.mark.mpi
+@pytest.mark.skipif(
+    MPI.COMM_WORLD.size != 2, reason="Only rank-2 MPI is supported with this test"
+)
+def test_standard_tomo_loader_read_block_adjust_for_darks_flats_two_procs():
+    IN_FILE_PATH = Path(__file__).parent.parent / "test_data/k11_diad/k11-18014.nxs"
+    DATA_PATH = "/entry/imaging/data"
+    IMAGE_KEY_PATH = "/entry/instrument/imaging/image_key"
+    ANGLES_CONFIG = RawAngles(data_path="/entry/imaging_sum/gts_theta_value")
+    DARKS_CONFIG = FLATS_CONFIG = DarksFlatsFileConfig(
+        file=IN_FILE_PATH,
+        data_path=DATA_PATH,
+        image_key_path=IMAGE_KEY_PATH,
+    )
+    PREVIEW_CONFIG = PreviewConfig(
+        angles=PreviewDimConfig(start=0, stop=3201),
+        detector_y=PreviewDimConfig(start=0, stop=22),
+        detector_x=PreviewDimConfig(start=0, stop=26),
+    )
+    SLICING_DIM = 0
+    COMM = MPI.COMM_WORLD
+
+    with mock.patch(
+        "httomo.darks_flats.get_darks_flats",
+        return_value=(np.zeros(1), np.zeros(1)),
+    ):
+        loader = StandardTomoLoader(
+            in_file=IN_FILE_PATH,
+            data_path=DATA_PATH,
+            image_key_path=IMAGE_KEY_PATH,
+            darks=DARKS_CONFIG,
+            flats=FLATS_CONFIG,
+            angles=ANGLES_CONFIG,
+            preview_config=PREVIEW_CONFIG,
+            slicing_dim=SLICING_DIM,
+            comm=COMM,
+        )
+
+    BLOCK_START = 2
+    BLOCK_LENGTH = 4
+    block = loader.read_block(BLOCK_START, BLOCK_LENGTH)
+
+    DATA_SHAPE = (3000, 22, 26)
+    CHUNK_SHAPE = (
+        DATA_SHAPE[0] // 2,
+        DATA_SHAPE[1],
+        DATA_SHAPE[2],
+    )
+
+    # Darks/flats are at indices 0 to 99 (and 3101 to 3200), projection data starts at index
+    # 100
+    PROJS_SHIFT = 100
+    projs_start = PROJS_SHIFT if COMM.rank == 0 else PROJS_SHIFT + CHUNK_SHAPE[0]
+    # Index of block relative to the chunk it belongs to
+    expected_chunk_index = (BLOCK_START, 0, 0)
+    # Index of block relative to the global data it belongs to (ie, includes chunk shift - this
+    # will differ across two procs)
+    expected_block_global_index = (
+        (BLOCK_START, 0, 0)
+        if COMM.rank == 0
+        else (DATA_SHAPE[0] // 2 + BLOCK_START, 0, 0)
+    )
+
+    with h5py.File(IN_FILE_PATH, "r") as f:
+        dataset: h5py.Dataset = f[DATA_PATH]
+        projs: np.ndarray = dataset[
+            projs_start + BLOCK_START : projs_start + BLOCK_START + BLOCK_LENGTH
+        ]
+
+    assert block.global_index == expected_block_global_index
+    assert block.chunk_index == expected_chunk_index
+    assert block.data.shape[SLICING_DIM] == BLOCK_LENGTH
+    np.testing.assert_array_equal(block.data, projs)
+
+
+def test_standard_tomo_loader_generates_block_with_angles():
+    IN_FILE_PATH = Path(__file__).parent.parent / "test_data/tomo_standard.nxs"
+    ANGLES_PATH = "/entry1/tomo_entry/data/rotation_angle"
+    BLOCK_START = 0
+    BLOCK_LENGTH = 2
+
+    with mock.patch(
+        "httomo.darks_flats.get_darks_flats",
+        return_value=(np.zeros(1), np.zeros(1)),
+    ):
+        loader = make_standard_tomo_loader()
+
+    block = loader.read_block(BLOCK_START, BLOCK_LENGTH)
+
+    with h5py.File(IN_FILE_PATH, "r") as f:
+        dataset: h5py.Dataset = f[ANGLES_PATH]
+        angles = np.deg2rad(dataset[...])
+
+    np.testing.assert_array_equal(block.angles, angles)
+
+
+def test_standard_tomo_loader_user_defined_angles(
+    standard_data_path: str,
+    standard_image_key_path: str,
+    standard_data_darks_flats_config: DarksFlatsFileConfig,
+):
+    PREVIEW_CONFIG = PreviewConfig(
+        angles=PreviewDimConfig(start=0, stop=180),
+        detector_y=PreviewDimConfig(start=0, stop=128),
+        detector_x=PreviewDimConfig(start=0, stop=160),
+    )
+    SLICING_DIM = 0
+    COMM = MPI.COMM_WORLD
+    # Override angles in raw data with the config for some arbitrary array
+    USER_DEFINED_ANGLES = UserDefinedAngles(
+        start_angle=0,
+        stop_angle=180,
+        angles_total=720,
+    )
+    EXPECTED_ANGLES = np.deg2rad(np.linspace(
+        USER_DEFINED_ANGLES.start_angle,
+        USER_DEFINED_ANGLES.stop_angle,
+        USER_DEFINED_ANGLES.angles_total,
+    ))
+
+    with mock.patch(
+        "httomo.darks_flats.get_darks_flats",
+        return_value=(np.zeros(1), np.zeros(1)),
+    ):
+        loader = StandardTomoLoader(
+            in_file=Path(__file__).parent.parent / "test_data/tomo_standard.nxs",
+            data_path=standard_data_path,
+            image_key_path=standard_image_key_path,
+            darks=standard_data_darks_flats_config,
+            flats=standard_data_darks_flats_config,
+            preview_config=PREVIEW_CONFIG,
+            angles=USER_DEFINED_ANGLES,
+            slicing_dim=SLICING_DIM,
+            comm=COMM,
+        )
+
+    BLOCK_START = 0
+    BLOCK_LENGTH = 2
+    block = loader.read_block(BLOCK_START, BLOCK_LENGTH)
+    np.testing.assert_array_equal(block.angles, EXPECTED_ANGLES)
+
+
+def test_standard_tomo_loader_closes_file(mocker: MockerFixture):
+    with mock.patch(
+        "httomo.darks_flats.get_darks_flats",
+        return_value=(np.zeros(1), np.zeros(1)),
+    ):
+        loader = make_standard_tomo_loader()
+
+    file_close = mocker.patch.object(loader._h5file, "close")
+    loader.finalize()
+    file_close.assert_called_once()
+
+
+def test_standard_tomo_loader_raises_error_slicing_dim(
+    standard_data_darks_flats_config: DarksFlatsFileConfig,
+):
+    IN_FILE_PATH = Path(__file__).parent.parent / "test_data/tomo_standard.nxs"
+    ANGLES_CONFIG = RawAngles(data_path="/entry1/tomo_entry/data/rotation_angle")
+    PREVIEW_CONFIG = PreviewConfig(
+        angles=PreviewDimConfig(start=0, stop=180),
+        detector_y=PreviewDimConfig(start=0, stop=128),
+        detector_x=PreviewDimConfig(start=0, stop=160),
+    )
+    SLICING_DIM = 1
+    COMM = MPI.COMM_WORLD
+
+    with (
+        mock.patch(
+            "httomo.darks_flats.get_darks_flats",
+            return_value=(np.zeros(1), np.zeros(1)),
+        ),
+        pytest.raises(NotImplementedError),
+    ):
+        _ = StandardTomoLoader(
+            in_file=IN_FILE_PATH,
+            data_path=standard_data_darks_flats_config.data_path,
+            image_key_path=standard_data_darks_flats_config.image_key_path,
+            darks=standard_data_darks_flats_config,
+            flats=standard_data_darks_flats_config,
+            preview_config=PREVIEW_CONFIG,
+            angles=ANGLES_CONFIG,
+            slicing_dim=SLICING_DIM,
+            comm=COMM,
+        )
diff --git a/tests/method_wrappers/__init__.py b/tests/method_wrappers/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/method_wrappers/test_data_reducer.py b/tests/method_wrappers/test_data_reducer.py
new file mode 100644
index 000000000..f9688deca
--- /dev/null
+++ b/tests/method_wrappers/test_data_reducer.py
@@ -0,0 +1,85 @@
+import math
+from httomo.method_wrappers import make_method_wrapper
+from httomo.method_wrappers.datareducer import DatareducerWrapper
+from httomo.runner.auxiliary_data import AuxiliaryData
+from httomo.runner.dataset import DataSetBlock
+from ..testing_utils import make_mock_repo
+
+
+import numpy as np
+from mpi4py import MPI
+from pytest_mock import MockerFixture
+
+
+def test_datareducer(mocker: MockerFixture):
+    class FakeModule:
+        def data_reducer(x):
+            return 2 * x
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker),
+        "mocked_module_path.morph",
+        "data_reducer",
+        MPI.COMM_WORLD,
+    )
+    assert isinstance(wrp, DatareducerWrapper)
+
+    GLOBAL_SHAPE = (10, 20, 30)
+    darks = 4 * np.ones((2, GLOBAL_SHAPE[1], GLOBAL_SHAPE[2]), dtype=np.float32)
+    flats = 3 * np.ones((3, GLOBAL_SHAPE[1], GLOBAL_SHAPE[2]), dtype=np.float32)
+    aux_data = AuxiliaryData(
+        angles=np.linspace(0, math.pi, GLOBAL_SHAPE[0], dtype=np.float32),
+        darks=darks,
+        flats=flats,
+    )
+    darks_setter_spy = mocker.spy(aux_data, "set_darks")
+    flats_setter_spy = mocker.spy(aux_data, "set_flats")
+    data = np.ones(GLOBAL_SHAPE, dtype=np.float32)
+
+    block1 = DataSetBlock(
+        data[0:2, :, :],
+        aux_data=aux_data,
+        slicing_dim=0,
+        block_start=0,
+        chunk_start=0,
+        chunk_shape=GLOBAL_SHAPE,
+        global_shape=GLOBAL_SHAPE,
+    )
+    block2 = DataSetBlock(
+        data[2:4, :, :],
+        aux_data=aux_data,
+        slicing_dim=0,
+        block_start=2,
+        chunk_start=0,
+        chunk_shape=GLOBAL_SHAPE,
+        global_shape=GLOBAL_SHAPE,
+    )
+
+    # darks/flats setters on aux data object should be called when processing first block
+    assert wrp._flats_darks_processed is False
+    assert darks_setter_spy.call_count == 0
+    assert flats_setter_spy.call_count == 0
+    block1 = wrp.execute(block1)
+    assert wrp._flats_darks_processed is True
+    assert darks_setter_spy.call_count == 1
+    assert flats_setter_spy.call_count == 1
+
+    # we double flats and darks only, so we expect them to be twice the original
+    np.testing.assert_array_equal(block1.data, data[0:2, :, :])
+    np.testing.assert_array_equal(block1.flats, 2 * flats)
+    np.testing.assert_array_equal(block1.darks, 2 * darks)
+
+    # darks/flats setters on aux data object should NOT be called when processing second block
+    block2 = wrp.execute(block2)
+    assert darks_setter_spy.call_count == 1
+    assert flats_setter_spy.call_count == 1
+
+    # darks/flats should NOT have been doubled a second time when the second block was
+    # processed (ie, they should still be only double the original, not quadruple the original)
+    np.testing.assert_array_equal(block1.data, data[0:2, :, :])
+    np.testing.assert_array_equal(block1.flats, 2 * flats)
+    np.testing.assert_array_equal(block1.darks, 2 * darks)
+    np.testing.assert_array_equal(block2.data, data[2:4, :, :])
+    np.testing.assert_array_equal(block2.flats, 2 * flats)
+    np.testing.assert_array_equal(block2.darks, 2 * darks)
diff --git a/tests/method_wrappers/test_dezinging.py b/tests/method_wrappers/test_dezinging.py
new file mode 100644
index 000000000..16a6ba4ab
--- /dev/null
+++ b/tests/method_wrappers/test_dezinging.py
@@ -0,0 +1,90 @@
+import math
+from httomo.method_wrappers import make_method_wrapper
+from httomo.method_wrappers.dezinging import DezingingWrapper
+from httomo.runner.auxiliary_data import AuxiliaryData
+from httomo.runner.dataset import DataSetBlock
+from ..testing_utils import make_mock_repo
+
+
+import numpy as np
+from mpi4py import MPI
+from pytest_mock import MockerFixture
+
+
+def test_dezinging(mocker: MockerFixture):
+    class FakeModule:
+        def remove_outlier(x, axis="auto"):
+            return 2 * x
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker),
+        "mocked_module_path.prep",
+        "remove_outlier",
+        MPI.COMM_WORLD,
+    )
+    assert isinstance(wrp, DezingingWrapper)
+
+    GLOBAL_SHAPE = (10, 20, 30)
+    darks = 4 * np.ones((2, GLOBAL_SHAPE[1], GLOBAL_SHAPE[2]), dtype=np.float32)
+    flats = 3 * np.ones((3, GLOBAL_SHAPE[1], GLOBAL_SHAPE[2]), dtype=np.float32)
+    aux_data = AuxiliaryData(
+        angles=np.linspace(0, math.pi, GLOBAL_SHAPE[0], dtype=np.float32),
+        darks=darks,
+        flats=flats,
+    )
+
+    darks_setter_spy = mocker.spy(aux_data, "set_darks")
+    flats_setter_spy = mocker.spy(aux_data, "set_flats")
+    data = np.ones(GLOBAL_SHAPE, dtype=np.float32)
+
+    block1 = DataSetBlock(
+        data[0:2, :, :],
+        aux_data=aux_data,
+        slicing_dim=0,
+        block_start=0,
+        chunk_start=0,
+        chunk_shape=GLOBAL_SHAPE,
+        global_shape=GLOBAL_SHAPE,
+    )
+
+    block2 = DataSetBlock(
+        data[2:4, :, :],
+        aux_data=aux_data,
+        slicing_dim=0,
+        block_start=2,
+        chunk_start=0,
+        chunk_shape=GLOBAL_SHAPE,
+        global_shape=GLOBAL_SHAPE,
+    )
+
+    # darks/flats setters on aux data object should be called when processing first block
+    assert wrp._flats_darks_processed is False
+    assert darks_setter_spy.call_count == 0
+    assert flats_setter_spy.call_count == 0
+    newblock1 = wrp.execute(block1)
+    assert wrp._flats_darks_processed is True
+    # it should only update the darks/flats once, but still apply to the data
+    assert darks_setter_spy.call_count == 1
+    assert flats_setter_spy.call_count == 1
+
+    # we double them all, so we expect all 3 to be twice the input
+    np.testing.assert_array_equal(newblock1.data, 2 * data[0:2, :, :])
+    np.testing.assert_array_equal(newblock1.flats, 2 * flats)
+    np.testing.assert_array_equal(newblock1.darks, 2 * darks)
+
+    newblock2 = wrp.execute(block2)
+    np.testing.assert_array_equal(newblock2.data, 2 * data[2:4, :, :])
+    np.testing.assert_array_equal(newblock2.flats, 2 * flats)
+    np.testing.assert_array_equal(newblock2.darks, 2 * darks)
+    # it should only update the darks/flats once, but still apply to the data
+    assert darks_setter_spy.call_count == 1
+    assert flats_setter_spy.call_count == 1
+
+    # the flats, darks in original dataset should have changed by reference
+    aux_flats = aux_data.get_flats()
+    aux_darks = aux_data.get_darks()
+    assert aux_flats is not None
+    assert aux_darks is not None
+    np.testing.assert_array_equal(aux_flats, 2 * flats)
+    np.testing.assert_array_equal(aux_darks, 2 * darks)
diff --git a/tests/method_wrappers/test_generic.py b/tests/method_wrappers/test_generic.py
new file mode 100644
index 000000000..3a4ca173d
--- /dev/null
+++ b/tests/method_wrappers/test_generic.py
@@ -0,0 +1,630 @@
+from typing import List, Optional, Union
+
+import numpy as np
+from httomo.method_wrappers import make_method_wrapper
+from httomo.method_wrappers.generic import GenericMethodWrapper
+from httomo.runner.dataset import DataSetBlock
+from httomo.runner.methods_repository_interface import GpuMemoryRequirement
+from httomo.runner.output_ref import OutputRef
+from httomo.utils import Pattern, gpu_enabled, xp
+from ..testing_utils import make_mock_repo, make_test_method
+
+import pytest
+from mpi4py import MPI
+from pytest_mock import MockerFixture
+
+
+def test_generic_get_name_and_paths(mocker: MockerFixture):
+    class FakeModule:
+        def fake_method(data):
+            return data
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker),
+        "testmodule.path",
+        "fake_method",
+        MPI.COMM_WORLD,
+    )
+    assert isinstance(wrp, GenericMethodWrapper)
+    assert wrp.method_name == "fake_method"
+    assert wrp.module_path == "testmodule.path"
+    assert wrp.package_name == "testmodule"
+    assert wrp.task_id == ""
+    assert wrp.save_result is False
+
+
+def test_generic_set_task_id(mocker: MockerFixture):
+    class FakeModule:
+        def fake_method(data):
+            return data
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker),
+        "testmodule.path",
+        "fake_method",
+        MPI.COMM_WORLD,
+        task_id="fake_method_id"
+    )
+    
+    assert wrp.task_id == "fake_method_id"
+
+def test_generic_execute_transfers_to_gpu(mocker: MockerFixture, dummy_block: DataSetBlock):
+    class FakeModule:
+        def fake_method(data):
+            return data
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker, implementation="gpu_cupy"),
+        "module_path",
+        "fake_method",
+        MPI.COMM_WORLD,
+    )
+    res = wrp.execute(dummy_block)
+
+    assert res.is_gpu == gpu_enabled
+
+
+@pytest.mark.skipif(
+    not gpu_enabled or xp.cuda.runtime.getDeviceCount() == 0,
+    reason="skipped as cupy is not available",
+)
+@pytest.mark.cupy
+def test_generic_excute_measures_gpu_times(dummy_block: DataSetBlock, mocker: MockerFixture
+):
+    class FakeModule:
+        def fake_method(data):
+            return xp.exp(data) + data  # make sure some kernel code is run
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker, implementation="gpu_cupy"),
+        "module_path",
+        "fake_method",
+        MPI.COMM_WORLD,
+    )
+    wrp.execute(dummy_block)
+
+    if gpu_enabled:
+        assert wrp.gpu_time.host2device > 0.0
+        assert wrp.gpu_time.device2host == 0.0
+        assert wrp.gpu_time.kernel > 0.0
+
+
+def test_generic_execute_calls_pre_post_process(
+    mocker: MockerFixture, dummy_block: DataSetBlock
+):
+    class FakeModule:
+        def fake_method(data):
+            return data
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker, implementation="gpu_cupy"),
+        "module_path",
+        "fake_method",
+        MPI.COMM_WORLD,
+    )
+    prep = mocker.patch.object(wrp, "_preprocess_data", return_value=dummy_block)
+    post = mocker.patch.object(wrp, "_postprocess_data", return_value=dummy_block)
+    trans = mocker.patch.object(wrp, "_transfer_data", return_value=dummy_block)
+
+    wrp.execute(dummy_block)
+
+    prep.assert_called_once_with(dummy_block)
+    post.assert_called_once_with(dummy_block)
+    trans.assert_called_once_with(dummy_block)
+
+
+def test_generic_fails_with_wrong_returntype(
+    mocker: MockerFixture, dummy_block: DataSetBlock
+):
+    class FakeModule:
+        def fake_method(data):
+            # returning None should not be allowed for generic wrapper,
+            # it must be a cpu or gpu array (data)
+            return None
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker), "mocked_module_path", "fake_method", MPI.COMM_WORLD
+    )
+    with pytest.raises(ValueError) as e:
+        wrp.execute(dummy_block)
+    assert "return type" in str(e)
+
+
+def test_generic_sets_gpuid(mocker: MockerFixture, dummy_block: DataSetBlock):
+    mocker.patch("httomo.method_wrappers.generic.gpu_enabled", True)
+    mocker.patch(
+        "httomo.method_wrappers.generic.xp.cuda.runtime.getDeviceCount", return_value=4
+    )
+    mocker.patch("httomo.method_wrappers.generic.httomo.globals.gpu_id", -1)
+    mocker.patch("httomo.method_wrappers.generic.mpiutil.local_rank", 3)
+
+    class FakeModule:
+        def fake_method(data, gpu_id: int):
+            assert gpu_id == 3
+            return data
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker), "mocked_module_path", "fake_method", MPI.COMM_WORLD
+    )
+
+    wrp.execute(dummy_block)
+
+
+def test_generic_fails_for_gpumethods_with_no_gpu(mocker: MockerFixture):
+    mocker.patch("httomo.method_wrappers.generic.gpu_enabled", False)
+
+    class FakeModule:
+        def fake_method(data):
+            return data
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+
+    with pytest.raises(ValueError) as e:
+        make_method_wrapper(
+            make_mock_repo(mocker, implementation="gpu_cupy"),
+            "mocked_module_path",
+            "fake_method",
+            MPI.COMM_WORLD,
+        )
+
+    assert "GPU is not available" in str(e)
+
+
+def test_generic_passes_communicator_if_needed(
+    mocker: MockerFixture, dummy_block: DataSetBlock
+):
+    class FakeModule:
+        def fake_method(data, comm: Optional[MPI.Comm] = None):
+            assert comm is not None
+            return data
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker), "mocked_module_path", "fake_method", MPI.COMM_WORLD
+    )
+
+    wrp.execute(dummy_block)
+
+
+def test_generic_transforms_auto_axis(
+    mocker: MockerFixture, dummy_block: DataSetBlock
+):    
+
+    PATTERN = Pattern.projection
+    class FakeModule:
+        def fake_method(data, axis: int):
+            assert axis == PATTERN.value
+            return data
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker, pattern=PATTERN), "mocked_module_path", "fake_method",
+        MPI.COMM_WORLD, axis="auto",
+    )
+
+    wrp.execute(dummy_block)
+
+
+def test_generic_allows_parameters_with_defaults(
+    mocker: MockerFixture, dummy_block: DataSetBlock
+):
+    class FakeModule:
+        def fake_method(data, defaultpar: int = 10):
+            assert defaultpar == 10
+            return data
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker), "mocked_module_path", "fake_method", MPI.COMM_WORLD
+    )
+
+    wrp.execute(dummy_block)
+
+
+def test_generic_build_kwargs_parameter_not_given(
+    mocker: MockerFixture, dummy_block: DataSetBlock
+):
+    class FakeModule:
+        def fake_method(data, param):
+            return data
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker), "mocked_module_path", "fake_method", MPI.COMM_WORLD
+    )
+    with pytest.raises(ValueError) as e:
+        wrp.execute(dummy_block)
+
+    assert "Cannot map method parameter param to a value" in str(e)
+
+
+def test_generic_access_outputref_params(mocker: MockerFixture, dummy_block: DataSetBlock):
+    class FakeModule:
+        def fake_method(data, param):
+            assert param == 42
+            return data
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker), "mocked_module_path", "fake_method", MPI.COMM_WORLD
+    )
+    m = make_test_method(mocker)
+    mocker.patch.object(m, "get_side_output", return_value={"somepar": 42})
+    wrp["param"] = OutputRef(m, "somepar")
+
+    wrp.execute(dummy_block)
+
+
+def test_generic_access_outputref_params_kwargs(
+    mocker: MockerFixture, dummy_block: DataSetBlock
+):
+    class FakeModule:
+        def fake_method(data, **kwargs):
+            assert kwargs["param"] == 42
+            return data
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker), "mocked_module_path", "fake_method", MPI.COMM_WORLD
+    )
+    m = make_test_method(mocker)
+    mocker.patch.object(m, "get_side_output", return_value={"somepar": 42})
+    wrp["param"] = OutputRef(m, "somepar")
+
+    wrp.execute(dummy_block)
+
+
+def test_generic_different_data_parameter_name(
+    mocker: MockerFixture, dummy_block: DataSetBlock
+):
+    class FakeModule:
+        def fake_method(array):
+            np.testing.assert_array_equal(array, 42)
+            return array
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker), "mocked_module_path", "fake_method", MPI.COMM_WORLD
+    )
+    dummy_block.data[:] = 42
+    wrp.execute(dummy_block)
+
+
+def test_generic_for_method_with_kwargs(mocker: MockerFixture, dummy_block: DataSetBlock):
+    class FakeModule:
+        def fake_method(data, param, **kwargs):
+            assert param == 42.0
+            assert kwargs == {"extra": 123}
+            return data
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker),
+        "mocked_module_path",
+        "fake_method",
+        MPI.COMM_WORLD,
+        param=42.0,
+        extra=123,
+    )
+
+    wrp.execute(dummy_block)
+
+
+def test_generic_sets_config_params_constructor(
+    mocker: MockerFixture, dummy_block: DataSetBlock
+):
+    class FakeModule:
+        def param_tester(data, param):
+            assert param == 42
+            return data
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker),
+        "mocked_module_path",
+        "param_tester",
+        MPI.COMM_WORLD,
+        param=42,
+    )
+    wrp.execute(dummy_block)
+
+    assert wrp["param"] == 42
+
+
+def test_generic_sets_config_params_setter(
+    mocker: MockerFixture, dummy_block: DataSetBlock
+):
+    class FakeModule:
+        def param_tester(data, param):
+            assert param == 42
+            return data
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker),
+        "mocked_module_path",
+        "param_tester",
+        MPI.COMM_WORLD,
+    )
+    wrp["param"] = 42
+    wrp.execute(dummy_block)
+
+    assert wrp["param"] == 42
+
+
+def test_generic_sets_config_params_setter_not_in_arguments(
+    mocker: MockerFixture, dummy_block: DataSetBlock
+):
+    class FakeModule:
+        def param_tester(data, param):
+            assert param == 42
+            return data
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker),
+        "mocked_module_path",
+        "param_tester",
+        MPI.COMM_WORLD,
+    )
+    with pytest.raises(ValueError):
+        wrp["param_not_existing"] = 42
+
+
+def test_generic_sets_config_params_append_dict(
+    mocker: MockerFixture, dummy_block: DataSetBlock
+):
+    class FakeModule:
+        def param_tester(data, param1, param2):
+            assert param1 == 42
+            assert param2 == 43
+            return data
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker),
+        "mocked_module_path",
+        "param_tester",
+        MPI.COMM_WORLD,
+    )
+    wrp.append_config_params({"param1": 42, "param2": 43})
+    wrp.execute(dummy_block)
+
+    assert wrp["param1"] == 42
+    assert wrp["param2"] == 43
+
+
+def test_generic_passes_darks_flats_to_normalize(
+    mocker: MockerFixture, dummy_block: DataSetBlock
+):
+    class FakeModule:
+        def normalize_tester(data, flats, darks):
+            np.testing.assert_array_equal(data, 1)
+            np.testing.assert_array_equal(darks, 2)
+            np.testing.assert_array_equal(flats, 3)
+            return data
+
+    importmock = mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker),
+        "mocked_module_path",
+        "normalize_tester",
+        MPI.COMM_WORLD,
+    )
+    dummy_block.data[:] = 1
+    dummy_block.darks[:] = 2
+    dummy_block.flats[:] = 3
+
+    wrp.execute(dummy_block)
+
+    importmock.assert_called_once_with("mocked_module_path")
+
+
+@pytest.mark.parametrize(
+    "implementation,is_cpu,is_gpu,cupyrun",
+    [
+        ("cpu", True, False, False),
+        ("gpu", False, True, False),
+        ("gpu_cupy", False, True, True),
+    ],
+)
+def test_generic_method_queries(
+    mocker: MockerFixture,
+    implementation: str,
+    is_cpu: bool,
+    is_gpu: bool,
+    cupyrun: bool,
+):
+    class FakeModule:
+        def test_method(data):
+            return data
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+
+    memory_gpu = [GpuMemoryRequirement(dataset="tomo", multiplier=1.2, method="direct")]
+    wrp = make_method_wrapper(
+        make_mock_repo(
+            mocker,
+            pattern=Pattern.projection,
+            output_dims_change=True,
+            implementation=implementation,
+            memory_gpu=memory_gpu,
+        ),
+        "mocked_module_path",
+        "test_method",
+        MPI.COMM_WORLD,
+    )
+
+    assert wrp.pattern == Pattern.projection
+    assert wrp.output_dims_change is True
+    assert wrp.implementation == implementation
+    assert wrp.is_cpu == is_cpu
+    assert wrp.is_gpu == is_gpu
+    assert wrp.cupyrun == cupyrun
+    assert wrp.memory_gpu == memory_gpu
+
+
+@pytest.mark.parametrize("implementation", ["cpu", "gpu", "gpu_cupy"])
+@pytest.mark.parametrize(
+    "memory_gpu",
+    [
+        [GpuMemoryRequirement(dataset="tomo", multiplier=2.0, method="direct")],
+        [GpuMemoryRequirement(dataset="tomo", multiplier=0.0, method="direct")],
+        [],
+    ],
+)
+def test_generic_calculate_max_slices_direct(
+    mocker: MockerFixture,
+  dummy_block: DataSetBlock,
+    implementation: str,
+    memory_gpu: List[GpuMemoryRequirement],
+):
+    class FakeModule:
+        def test_method(data):
+            return data
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+
+    wrp = make_method_wrapper(
+        make_mock_repo(
+            mocker,
+            pattern=Pattern.projection,
+            output_dims_change=True,
+            implementation=implementation,
+            memory_gpu=memory_gpu,
+        ),
+        "mocked_module_path",
+        "test_method",
+        MPI.COMM_WORLD,
+    )
+    shape_t = list(dummy_block.chunk_shape)
+    shape_t.pop(0)
+    shape = (shape_t[0], shape_t[1])
+    databytes = shape[0] * shape[1] * dummy_block.data.itemsize
+    max_slices_expected = 5
+    multiplier = float(memory_gpu[0].multiplier if memory_gpu != [] and memory_gpu[0].multiplier is not None else 1)
+    available_memory_in = int(databytes * max_slices_expected * multiplier)
+    if available_memory_in == 0:
+        available_memory_in = 5
+    max_slices, available_memory = wrp.calculate_max_slices(
+        dummy_block.data.dtype,
+        shape,
+        available_memory_in,
+    )
+
+    if gpu_enabled and implementation != "cpu":
+        assert max_slices == max_slices_expected
+    else:
+        assert max_slices > dummy_block.data.shape[0]
+    assert available_memory == available_memory_in
+
+
+def test_generic_calculate_max_slices_module(
+    mocker: MockerFixture, dummy_block: DataSetBlock
+):
+    class FakeModule:
+        def test_method(data):
+            return data
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+
+    memory_gpu = [
+        GpuMemoryRequirement(dataset="tomo", multiplier=None, method="module")
+    ]
+    repo = make_mock_repo(
+        mocker,
+        pattern=Pattern.projection,
+        output_dims_change=True,
+        implementation="gpu_cupy",
+        memory_gpu=memory_gpu,
+    )
+    memcalc_mock = mocker.patch.object(
+        repo.query("", ""), "calculate_memory_bytes", return_value=(1234, 5678)
+    )
+    wrp = make_method_wrapper(
+        repo,
+        "mocked_module_path",
+        "test_method",
+        MPI.COMM_WORLD,
+    )
+    shape_t = list(dummy_block.chunk_shape)
+    shape_t.pop(0)
+    shape = (shape_t[0], shape_t[1])
+    max_slices, available_memory = wrp.calculate_max_slices(
+        dummy_block.data.dtype,
+        shape,
+        1_000_000_000,
+    )
+
+    if gpu_enabled:
+        assert max_slices == (1_000_000_000 - 5678) // 1234
+        assert available_memory == 1_000_000_000
+        memcalc_mock.assert_called_once_with(tuple(shape), dummy_block.data.dtype)
+    else:
+        assert max_slices > dummy_block.chunk_shape[0]
+        assert available_memory == 1_000_000_000
+
+
+def test_generic_calculate_output_dims(mocker: MockerFixture):
+    class FakeModule:
+        def test_method(data, testparam):
+            return data
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+
+    memory_gpu: List[GpuMemoryRequirement] = []
+    repo = make_mock_repo(
+        mocker,
+        pattern=Pattern.projection,
+        output_dims_change=True,
+        implementation="gpu_cupy",
+        memory_gpu=memory_gpu,
+    )
+    memcalc_mock = mocker.patch.object(
+        repo.query("", ""), "calculate_output_dims", return_value=(1234, 5678)
+    )
+    wrp = make_method_wrapper(
+        repo,
+        "mocked_module_path",
+        "test_method",
+        MPI.COMM_WORLD,
+    )
+    wrp["testparam"] = 32
+
+    dims = wrp.calculate_output_dims((10, 10))
+
+    assert dims == (1234, 5678)
+    memcalc_mock.assert_called_with((10, 10), testparam=32)
+
+
+def test_generic_calculate_output_dims_no_change(mocker: MockerFixture):
+    class FakeModule:
+        def test_method(data):
+            return data
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+
+    memory_gpu: List[GpuMemoryRequirement] = []
+    wrp = make_method_wrapper(
+        make_mock_repo(
+            mocker,
+            pattern=Pattern.projection,
+            output_dims_change=False,
+            implementation="gpu_cupy",
+            memory_gpu=memory_gpu,
+        ),
+        "mocked_module_path",
+        "test_method",
+        MPI.COMM_WORLD,
+    )
+
+    dims = wrp.calculate_output_dims((10, 10))
+
+    assert dims == (10, 10)
diff --git a/tests/method_wrappers/test_images.py b/tests/method_wrappers/test_images.py
new file mode 100644
index 000000000..c14fbbb38
--- /dev/null
+++ b/tests/method_wrappers/test_images.py
@@ -0,0 +1,89 @@
+import httomo
+from httomo.method_wrappers import make_method_wrapper
+from httomo.method_wrappers.images import ImagesWrapper
+from httomo.runner.auxiliary_data import AuxiliaryData
+from httomo.runner.dataset import DataSetBlock
+from httomo.utils import gpu_enabled, xp
+from ..testing_utils import make_mock_repo
+
+import pytest
+import numpy as np
+from mpi4py import MPI
+from pytest_mock import MockerFixture
+
+
+def test_save_to_images(mocker: MockerFixture):
+    class FakeModule:
+        def save_to_images(data, out_dir, axis, file_format, offset):
+            np.testing.assert_array_equal(data, 1)
+            assert out_dir == httomo.globals.run_out_dir
+            assert offset == 2
+            assert axis == 1
+            assert file_format == "tif"
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker, implementation="cpu"),
+        "mocked_module_path.images",
+        "save_to_images",
+        MPI.COMM_WORLD,
+        axis=1,
+        file_format="tif",
+    )
+    assert isinstance(wrp, ImagesWrapper)
+
+    # images is a sinogram method - slice in that dim
+    block = DataSetBlock(
+        data=np.ones((10, 8, 10), dtype=np.float32),
+        aux_data=AuxiliaryData(angles=np.ones(10, dtype=np.float32)),
+        block_start=2,
+        slicing_dim=1,
+        chunk_shape=(10, 10, 10),
+        global_shape=(10, 10, 10),
+    )
+    newblock = wrp.execute(block)
+
+    assert newblock == block
+
+
+def test_save_to_images_with_comm_rank_raises(mocker: MockerFixture):
+    class FakeModule:
+        def save_to_images(data, out_dir, comm_rank, axis, file_format):
+            pass
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    with pytest.raises(ValueError) as e:
+        make_method_wrapper(
+            make_mock_repo(mocker, implementation="cpu"),
+            "mocked_module_path.images",
+            "save_to_images",
+            MPI.COMM_WORLD,
+            axis=1,
+            file_format="tif",
+        )
+
+    assert "comm_rank" in str(e)
+
+
+@pytest.mark.skipif(
+    not gpu_enabled or xp.cuda.runtime.getDeviceCount() == 0,
+    reason="skipped as cupy is not available",
+)
+@pytest.mark.cupy
+def test_images_leaves_gpudata(mocker: MockerFixture, dummy_block: DataSetBlock):
+    class FakeModule:
+        def save_to_images(data, out_dir, offset):
+            assert getattr(data, "device", None) is None  # make sure it's on CPU
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker),
+        "mocked_module_path.images",
+        "save_to_images",
+        MPI.COMM_WORLD,
+    )
+    with xp.cuda.Device(0):
+        dummy_block.to_gpu()
+        new_dataset = wrp.execute(dummy_block)
+
+        assert new_dataset.is_gpu is True
diff --git a/tests/method_wrappers/test_reconstruction.py b/tests/method_wrappers/test_reconstruction.py
new file mode 100644
index 000000000..5004cc7c3
--- /dev/null
+++ b/tests/method_wrappers/test_reconstruction.py
@@ -0,0 +1,123 @@
+import math
+from httomo.method_wrappers import make_method_wrapper
+from httomo.method_wrappers.reconstruction import ReconstructionWrapper
+from httomo.runner.auxiliary_data import AuxiliaryData
+from httomo.runner.dataset import DataSetBlock
+from httomo.utils import Pattern
+from ..testing_utils import make_mock_repo
+
+
+import numpy as np
+from mpi4py import MPI
+from pytest_mock import MockerFixture
+
+
+def test_recon_handles_reconstruction_angle_reshape(mocker: MockerFixture):
+    GLOBAL_SHAPE = (10, 20, 30)
+    class FakeModule:
+        # we give the angles a different name on purpose
+        def recon_tester(data, theta):
+            np.testing.assert_array_equal(data, 1)
+            np.testing.assert_array_equal(theta, 2)
+            assert data.shape[0] == len(theta)
+            return data
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker, pattern=Pattern.sinogram),
+        "mocked_module_path.algorithm",
+        "recon_tester",
+        MPI.COMM_WORLD,
+    )
+    assert isinstance(wrp, ReconstructionWrapper)
+    
+    
+    aux_data = AuxiliaryData(angles=2.*np.ones(GLOBAL_SHAPE[0]+10, dtype=np.float32))
+    data = np.ones(GLOBAL_SHAPE, dtype=np.float32)
+    input = DataSetBlock(data[:, 0:3, :], slicing_dim=1, aux_data=aux_data, chunk_shape=GLOBAL_SHAPE, global_shape=GLOBAL_SHAPE)
+    
+    wrp.execute(input)
+    
+    assert aux_data.get_angles().shape[0] == GLOBAL_SHAPE[0]
+
+
+def test_recon_handles_reconstruction_axisswap(mocker: MockerFixture):
+    class FakeModule:
+        def recon_tester(data, theta):
+            return data.swapaxes(0, 1)
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker, swap_dims_on_output=True),
+        "mocked_module_path.algorithm",
+        "recon_tester",
+        MPI.COMM_WORLD,
+    )
+    block = DataSetBlock(data=np.ones((13, 14, 15), dtype=np.float32),
+        aux_data=AuxiliaryData(angles=np.ones(13, dtype=np.float32)),
+        slicing_dim=1
+    )
+    res = wrp.execute(block)
+
+    assert res.data.shape == (13, 14, 15)
+
+def test_recon_changes_global_shape_if_size_changes(mocker: MockerFixture):
+    class FakeModule:
+        def recon_tester(data, theta):
+            data = np.ones((30, data.shape[1], 15), dtype=np.float32)
+            return data.swapaxes(0, 1)
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker, swap_dims_on_output=True),
+        "mocked_module_path.algorithm",
+        "recon_tester",
+        MPI.COMM_WORLD,
+    )
+    block = DataSetBlock(data=np.ones((13, 3, 15), dtype=np.float32),
+        aux_data=AuxiliaryData(angles=np.ones(13, dtype=np.float32)),
+        slicing_dim=1,
+        global_shape=(13, 14, 15),
+        chunk_shape=(13, 14, 15)
+    )
+    res = wrp.execute(block)
+    
+    assert res.shape == (30, 3, 15)
+    assert res.global_shape == (30, 14, 15)
+    assert res.chunk_shape == (30, 14, 15)
+
+
+def test_recon_gives_recon_algorithm(mocker: MockerFixture):
+    class FakeModule:
+        def recon_tester(data, algorithm, center):
+            return data
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker),
+        "test.algorithm",
+        "recon_tester",
+        MPI.COMM_WORLD,
+    )
+
+    assert wrp.recon_algorithm is None
+    wrp["algorithm"] = "testalgo"
+    assert wrp.recon_algorithm == "testalgo"
+
+
+def test_recon_gives_no_recon_algorithm_if_not_recon_method(mocker: MockerFixture):
+    class FakeModule:
+        def tester(data, algorithm):
+            return data
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker),
+        "test.something",
+        "tester",
+        MPI.COMM_WORLD,
+    )
+
+    assert wrp.recon_algorithm is None
+    wrp["algorithm"] = "testalgo"
+    assert wrp.recon_algorithm is None
\ No newline at end of file
diff --git a/tests/method_wrappers/test_rotation.py b/tests/method_wrappers/test_rotation.py
new file mode 100644
index 000000000..0910ed64f
--- /dev/null
+++ b/tests/method_wrappers/test_rotation.py
@@ -0,0 +1,384 @@
+from typing import Union
+from unittest.mock import MagicMock
+import numpy as np
+from httomo.method_wrappers import make_method_wrapper
+from httomo.method_wrappers.rotation import RotationWrapper
+from httomo.runner.auxiliary_data import AuxiliaryData
+from httomo.runner.dataset import DataSetBlock
+from httomo.utils import Pattern, gpu_enabled, xp
+from ..testing_utils import make_mock_repo
+
+
+import pytest
+from mpi4py import MPI
+from pytest_mock import MockerFixture
+
+
+def test_rotation_fails_with_sinogram_method(mocker: MockerFixture):
+    class FakeModule:
+        def rotation_tester(data, ind=None):
+            return 42.0
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    with pytest.raises(NotImplementedError):
+        make_method_wrapper(
+            make_mock_repo(mocker, pattern=Pattern.sinogram),
+            "mocked_module_path.rotation",
+            "rotation_tester",
+            MPI.COMM_WORLD,
+        )
+
+
+def test_rotation_accumulates_blocks(mocker: MockerFixture):
+    GLOBAL_SHAPE = (10, 10, 30)
+    global_data = np.arange(np.prod(GLOBAL_SHAPE), dtype=np.float32).reshape(
+        GLOBAL_SHAPE
+    )
+    aux_data = AuxiliaryData(
+        angles=np.ones(GLOBAL_SHAPE[0], dtype=np.float32),
+        darks=2.0 * np.ones((2, GLOBAL_SHAPE[1], GLOBAL_SHAPE[2]), np.float32),
+        flats=3.0 * np.ones((2, GLOBAL_SHAPE[1], GLOBAL_SHAPE[2]), np.float32),
+    )
+    b1 = DataSetBlock(
+        data=global_data[0 : GLOBAL_SHAPE[0] // 2, :, :],
+        aux_data=aux_data,
+        block_start=0,
+        global_shape=GLOBAL_SHAPE,
+        chunk_shape=GLOBAL_SHAPE,
+    )
+    b2 = DataSetBlock(
+        data=global_data[GLOBAL_SHAPE[0] // 2 :, :, :],
+        aux_data=aux_data,
+        block_start=GLOBAL_SHAPE[0] // 2,
+        global_shape=GLOBAL_SHAPE,
+        chunk_shape=GLOBAL_SHAPE,
+    )
+
+    class FakeModule:
+        def rotation_tester(data, ind=None):
+            assert data.ndim == 2  # for 1 slice only
+            np.testing.assert_array_equal(
+                data, global_data[:, (GLOBAL_SHAPE[1] - 1) // 2, :]
+            )
+            return 42.0
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker, pattern=Pattern.projection),
+        "mocked_module_path.rotation",
+        "rotation_tester",
+        MPI.COMM_WORLD,
+        output_mapping={"cor": "cor"},
+    )
+    assert isinstance(wrp, RotationWrapper)
+    normalize = mocker.patch.object(
+        wrp, "normalize_sino", side_effect=lambda sino, flats, darks: sino
+    )
+
+    wrp.execute(b1)
+    normalize.assert_not_called()
+    wrp.execute(b2)
+    normalize.assert_called_once()
+    assert wrp.get_side_output() == {"cor": 42.0}
+
+
+@pytest.mark.parametrize("gpu", [False, True])
+@pytest.mark.parametrize("rank", [0, 1])
+@pytest.mark.parametrize("ind_par", ["mid", 2, None])
+def test_rotation_gathers_single_sino_slice(
+    mocker: MockerFixture,
+    rank: int,
+    ind_par: Union[str, int, None],
+    gpu: bool,
+):
+    GLOBAL_SHAPE = (10, 10, 30)
+    global_data = np.arange(np.prod(GLOBAL_SHAPE), dtype=np.float32).reshape(
+        GLOBAL_SHAPE
+    )
+    rank0_data = global_data[: GLOBAL_SHAPE[0] // 2, :, :]
+    rank1_data = global_data[GLOBAL_SHAPE[0] // 2 :, :, :]
+    aux_data = AuxiliaryData(
+        angles=np.ones(GLOBAL_SHAPE[0], dtype=np.float32),
+        darks=2.0 * np.ones((2, GLOBAL_SHAPE[1], GLOBAL_SHAPE[2]), np.float32),
+        flats=3.0 * np.ones((2, GLOBAL_SHAPE[1], GLOBAL_SHAPE[2]), np.float32),
+    )
+    block = DataSetBlock(
+        data=rank0_data if rank == 0 else rank1_data,
+        aux_data=aux_data,
+        slicing_dim=0,
+        block_start=0,
+        chunk_start=0 if rank == 0 else GLOBAL_SHAPE[1] // 2,
+        global_shape=GLOBAL_SHAPE,
+        chunk_shape=(GLOBAL_SHAPE[0] // 2, GLOBAL_SHAPE[1], GLOBAL_SHAPE[2]),
+    )
+
+    class FakeModule:
+        def rotation_tester(data, ind=None):
+            assert rank == 0  # for rank 1, it shouldn't be called
+            assert data.ndim == 2  # for 1 slice only
+            assert ind == 0
+            if ind_par == "mid" or ind_par is None:
+                xp.testing.assert_array_equal(
+                    global_data[:, (GLOBAL_SHAPE[1] - 1) // 2, :],
+                    data,
+                )
+            else:
+                xp.testing.assert_array_equal(global_data[:, ind_par, :], data)
+            return 42.0
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    comm = mocker.MagicMock()
+    comm.rank = rank
+    comm.size = 2
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker, pattern=Pattern.projection, implementation="gpu_cupy"),
+        "mocked_module_path.rotation",
+        "rotation_tester",
+        comm,
+    )
+    assert isinstance(wrp, RotationWrapper)
+
+    if ind_par is not None:
+        wrp["ind"] = ind_par
+
+    if gpu:
+        block.to_gpu()
+
+    if ind_par == "mid" or ind_par is None:
+        sino_slice = global_data[:, (GLOBAL_SHAPE[1] - 1) // 2, :]
+    else:
+        assert isinstance(ind_par, int)
+        sino_slice = global_data[:, ind_par, :]
+    mocker.patch.object(wrp, "_gather_sino_slice", side_effect=lambda s: sino_slice)
+    normalize = mocker.patch.object(
+        wrp, "normalize_sino", side_effect=lambda sino, f, d: sino
+    )
+
+    comm.bcast.return_value = 42.0
+
+    res = wrp.execute(block)
+
+    assert wrp.pattern == Pattern.projection
+    xp.testing.assert_array_equal(res.data, rank0_data if rank == 0 else rank1_data)
+    comm.bcast.assert_called_once()
+    if rank == 0:
+        normalize.assert_called_once()
+    else:
+        normalize.assert_not_called()
+
+
+@pytest.mark.parametrize("rank", [0, 1])
+def test_rotation_gather_sino_slice(mocker: MockerFixture, rank: int):
+    class FakeModule:
+        def rotation_tester(data, ind=None):
+            return 42.0
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    comm = mocker.MagicMock()
+    comm.rank = rank
+    comm.size = 2
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker, pattern=Pattern.projection),
+        "mocked_module_path.rotation",
+        "rotation_tester",
+        comm,
+    )
+    assert isinstance(wrp, RotationWrapper)
+    if rank == 0:
+        wrp.sino = np.arange(2 * 6, dtype=np.float32).reshape((2, 6))
+    else:
+        wrp.sino = np.arange(2 * 6, 5 * 6, dtype=np.float32).reshape((3, 6))
+
+    if rank == 0:
+        comm.gather.return_value = [2 * 6, 3 * 6]
+    else:
+        comm.gather.return_value = [2 * 6]
+
+    res = wrp._gather_sino_slice((5, 13, 6))
+
+    comm.Gatherv.assert_called_once()
+    if rank == 0:
+        assert res.shape == (5, 6)
+        comm.gather.assert_called_once_with(2 * 6)
+    else:
+        assert res is None
+        comm.gather.assert_called_once_with(3 * 6)
+
+
+def test_rotation_normalize_sino_no_darks_flats():
+    self_mock = MagicMock()  # this mocks self - as function only sets gpu time
+    ret = RotationWrapper.normalize_sino(
+        self_mock, np.ones((10, 10), dtype=np.float32), None, None
+    )
+
+    assert ret.shape == (10, 1, 10)
+    np.testing.assert_allclose(np.squeeze(ret), 1.0)
+
+
+def test_rotation_normalize_sino_same_darks_flats():
+
+    self_mock = MagicMock()  # this mocks self - as function only sets gpu time
+    ret = RotationWrapper.normalize_sino(
+        self_mock,
+        np.ones((10, 10), dtype=np.float32),
+        0.5
+        * np.ones(
+            (
+                10,
+                10,
+            ),
+            dtype=np.float32,
+        ),
+        0.5
+        * np.ones(
+            (
+                10,
+                10,
+            ),
+            dtype=np.float32,
+        ),
+    )
+
+    assert ret.shape == (10, 1, 10)
+    np.testing.assert_allclose(ret, 0.5)
+
+
+def test_rotation_normalize_sino_scalar():
+    self_mock = MagicMock()  # this mocks self - as function only sets gpu time
+    ret = RotationWrapper.normalize_sino(
+        self_mock,
+        np.ones((10, 10), dtype=np.float32),
+        0.5
+        * np.ones(
+            (
+                10,
+                10,
+            ),
+            dtype=np.float32,
+        ),
+        0.5
+        * np.ones(
+            (
+                10,
+                10,
+            ),
+            dtype=np.float32,
+        ),
+    )
+
+    assert ret.shape == (10, 1, 10)
+    np.testing.assert_allclose(ret, 0.5)
+
+
+def test_rotation_normalize_sino_different_darks_flats():
+    self_mock = MagicMock()  # this mocks self - as function only sets gpu time
+    ret = RotationWrapper.normalize_sino(
+        self_mock,
+        2.0 * np.ones((10, 10), dtype=np.float32),
+        1.0 * np.ones((10, 10), dtype=np.float32),
+        0.5 * np.ones((10, 10), dtype=np.float32),
+    )
+
+    assert ret.shape == (10, 1, 10)
+    np.testing.assert_allclose(np.squeeze(ret), 1.0)
+
+
+@pytest.mark.skipif(
+    not gpu_enabled or xp.cuda.runtime.getDeviceCount() == 0,
+    reason="skipped as cupy is not available",
+)
+@pytest.mark.cupy
+def test_rotation_normalize_sino_different_darks_flats_gpu():
+    self_mock = MagicMock()  # this mocks self - as function only sets gpu time
+    ret = RotationWrapper.normalize_sino(
+        self_mock,
+        2.0 * xp.ones((10, 10), dtype=np.float32),
+        1.0 * xp.ones((10, 10), dtype=np.float32),
+        0.5 * xp.ones((10, 10), dtype=np.float32),
+    )
+
+    assert ret.shape == (10, 1, 10)
+    assert getattr(ret, "device", None) is not None
+    xp.testing.assert_allclose(xp.squeeze(ret), 1.0)
+
+
+def test_rotation_180(mocker: MockerFixture):
+    class FakeModule:
+        def rotation_tester(data, ind):
+            return 42.0  # center of rotation
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker, pattern=Pattern.projection),
+        "mocked_module_path.rotation",
+        "rotation_tester",
+        MPI.COMM_WORLD,
+        output_mapping={"cor": "center"},
+        ind=5,
+    )
+
+    block = DataSetBlock(
+        data=np.ones((10, 10, 10), dtype=np.float32),
+        aux_data=AuxiliaryData(angles=np.ones(10, dtype=np.float32)),
+    )
+    new_block = wrp.execute(block)
+
+    assert wrp.get_side_output() == {"center": 42.0}
+    assert new_block == block  # note: not a deep comparison
+
+
+def test_rotation_pc_180(mocker: MockerFixture):
+    class FakeModule:
+        def find_center_pc(proj1, proj2=None):
+            return 42.0  # center of rotation
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker, pattern=Pattern.projection),
+        "mocked_module_path.rotation",
+        "find_center_pc",
+        MPI.COMM_WORLD,
+        output_mapping={"cor": "center"},
+    )
+
+    block = DataSetBlock(
+        data=np.ones((10, 10, 10), dtype=np.float32),
+        aux_data=AuxiliaryData(angles=np.ones(10, dtype=np.float32)),
+    )
+    new_block = wrp.execute(block)
+
+    assert wrp.get_side_output() == {"center": 42.0}
+    assert new_block == block  # note: not a deep comparison
+
+
+def test_rotation_360(mocker: MockerFixture):
+    class FakeModule:
+        def rotation_tester(data, ind):
+            # cor, overlap, side, overlap_position - from find_center_360
+            return 42.0, 3.0, 1, 10.0
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker, pattern=Pattern.projection),
+        "mocked_module_path.rotation",
+        "rotation_tester",
+        MPI.COMM_WORLD,
+        output_mapping={
+            "cor": "center",
+            "overlap": "overlap",
+            "overlap_position": "pos",
+        },
+        ind=5,
+    )
+    block = DataSetBlock(
+        data=np.ones((10, 10, 10), dtype=np.float32),
+        aux_data=AuxiliaryData(angles=np.ones(10, dtype=np.float32)),
+    )
+    new_block = wrp.execute(block)
+
+    assert wrp.get_side_output() == {
+        "center": 42.0,
+        "overlap": 3.0,
+        "pos": 10.0,
+    }
+    assert new_block == block  # note: not a deep comparison
diff --git a/tests/method_wrappers/test_save_intermediate.py b/tests/method_wrappers/test_save_intermediate.py
new file mode 100644
index 000000000..2518c3abf
--- /dev/null
+++ b/tests/method_wrappers/test_save_intermediate.py
@@ -0,0 +1,160 @@
+from pathlib import Path
+from typing import Tuple
+import pytest
+from pytest_mock import MockerFixture
+from httomo.method_wrappers import make_method_wrapper
+from httomo.method_wrappers.save_intermediate import SaveIntermediateFilesWrapper
+
+from httomo.utils import gpu_enabled
+from httomo.runner.dataset import DataSetBlock
+import h5py
+from mpi4py import MPI
+from httomo.runner.loader import LoaderInterface
+from httomo.runner.method_wrapper import MethodWrapper
+from ..testing_utils import make_mock_repo
+import httomo
+import numpy as np
+
+
+def test_save_intermediate(
+    mocker: MockerFixture, dummy_block: DataSetBlock, tmp_path: Path
+):
+    loader: LoaderInterface = mocker.create_autospec(
+        LoaderInterface, instance=True, detector_x=10, detector_y=20
+    )
+
+    class FakeModule:
+        def save_intermediate_data(
+            data,
+            global_shape: Tuple[int, int, int],
+            global_index: Tuple[int, int, int],
+            file: h5py.File,
+            path: str,
+            detector_x: int,
+            detector_y: int,
+            angles: np.ndarray,
+        ):
+            assert isinstance(data, np.ndarray)
+            assert data.shape == dummy_block.shape
+            assert global_index == (0, 0, 0)
+            assert global_shape == dummy_block.shape
+            assert Path(file.filename).name == "task1-testpackage-testmethod-XXX.h5"
+            assert detector_x == 10
+            assert detector_y == 20
+            assert path == "/data"
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    prev_method = mocker.create_autospec(
+        MethodWrapper,
+        instance=True,
+        task_id="task1",
+        package_name="testpackage",
+        method_name="testmethod",
+        recon_algorithm="XXX",
+    )
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker, implementation="gpu_cupy"),
+        "httomo.methods",
+        "save_intermediate_data",
+        MPI.COMM_WORLD,
+        loader=loader,
+        out_dir=tmp_path,
+        prev_method=prev_method,
+    )
+    assert isinstance(wrp, SaveIntermediateFilesWrapper)
+    res = wrp.execute(dummy_block)
+
+    assert res == dummy_block
+
+
+def test_save_intermediate_defaults_out_dir(mocker: MockerFixture, tmp_path: Path):
+    loader: LoaderInterface = mocker.create_autospec(
+        LoaderInterface, instance=True, detector_x=10, detector_y=20
+    )
+
+    class FakeModule:
+        def save_intermediate_data(
+            data,
+            global_shape: Tuple[int, int, int],
+            global_index: Tuple[int, int, int],
+            file: h5py.File,
+            path: str,
+            detector_x: int,
+            detector_y: int,
+            angles: np.ndarray,
+        ):
+            pass
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    prev_method = mocker.create_autospec(
+        MethodWrapper,
+        instance=True,
+        task_id="task1",
+        package_name="testpackage",
+        method_name="testmethod",
+        recon_algorithm="XXX",
+    )
+    mocker.patch.object(httomo.globals, "run_out_dir", tmp_path)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker, implementation="gpu_cupy"),
+        "httomo.methods",
+        "save_intermediate_data",
+        MPI.COMM_WORLD,
+        loader=loader,
+        prev_method=prev_method,
+    )
+    assert isinstance(wrp, SaveIntermediateFilesWrapper)
+    assert wrp._file.filename.startswith(str(tmp_path))
+
+
+@pytest.mark.parametrize("gpu", [False, True], ids=["CPU", "GPU"])
+def test_save_intermediate_leaves_gpu_data(
+    mocker: MockerFixture, dummy_block: DataSetBlock, tmp_path: Path, gpu: bool
+):
+    if gpu and not gpu_enabled:
+        pytest.skip("No GPU available")
+
+    loader: LoaderInterface = mocker.create_autospec(
+        LoaderInterface, instance=True, detector_x=10, detector_y=20
+    )
+
+    class FakeModule:
+        def save_intermediate_data(
+            data,
+            global_shape: Tuple[int, int, int],
+            global_index: Tuple[int, int, int],
+            file: h5py.File,
+            path: str,
+            detector_x: int,
+            detector_y: int,
+            angles: np.ndarray,
+        ):
+            assert isinstance(data, np.ndarray)
+            assert getattr(data, "device", None) is None
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    prev_method = mocker.create_autospec(
+        MethodWrapper,
+        instance=True,
+        task_id="task1",
+        package_name="testpackage",
+        method_name="testmethod",
+        recon_algorithm="XXX",
+    )
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker, implementation="gpu_cupy"),
+        "httomo.methods",
+        "save_intermediate_data",
+        MPI.COMM_WORLD,
+        loader=loader,
+        out_dir=tmp_path,
+        prev_method=prev_method,
+    )
+
+    if gpu is True:
+        dummy_block.to_gpu()
+
+    assert dummy_block.is_gpu == gpu
+    res = wrp.execute(dummy_block)
+
+    assert res.is_gpu == gpu
diff --git a/tests/method_wrappers/test_stats_calc.py b/tests/method_wrappers/test_stats_calc.py
new file mode 100644
index 000000000..81bd9292d
--- /dev/null
+++ b/tests/method_wrappers/test_stats_calc.py
@@ -0,0 +1,143 @@
+import pytest
+from httomo.method_wrappers import make_method_wrapper
+from httomo.method_wrappers.stats_calc import StatsCalcWrapper
+from httomo.runner.dataset import DataSetBlock
+from ..testing_utils import make_mock_repo
+
+from mpi4py import MPI
+from pytest_mock import MockerFixture
+from httomo.utils import gpu_enabled, xp
+
+
+
+def test_calculate_stats(mocker: MockerFixture, dummy_block: DataSetBlock):
+    class FakeModule:
+        def calculate_stats(data, comm):
+            # outputs min/max/sum/total_elements
+            return (1.2, 3.1, 42.0, 10)
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker),
+        "mocked_module_path.calculate_stats",
+        "calculate_stats",
+        MPI.COMM_SELF,
+        output_mapping={"glob_stats": "glob_stats"},
+    )
+    assert isinstance(wrp, StatsCalcWrapper)
+    wrp.execute(dummy_block)
+
+    assert wrp.get_side_output() == {
+        "glob_stats": (1.2, 3.1, 4.2, 10),  # computes mean (sum/total)
+    }
+
+
+def test_calculate_stats_supports_blockwise(
+    mocker: MockerFixture, dummy_block: DataSetBlock
+):
+    class FakeModule:
+        def calculate_stats(data, comm):
+            # outputs min/max/sum/total_elements
+            return (1.2, 3.1, 42.0, 10)
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker),
+        "mocked_module_path.calculate_stats",
+        "calculate_stats",
+        MPI.COMM_SELF,
+        output_mapping={"glob_stats": "glob_stats"},
+    )
+    assert isinstance(wrp, StatsCalcWrapper)
+    # execute with 2 blocks
+    b1 = DataSetBlock(
+        data=dummy_block.data[:2, :, :],
+        aux_data=dummy_block.aux_data,
+        slicing_dim=0,
+        chunk_shape=dummy_block.chunk_shape,
+        global_shape=dummy_block.global_shape,
+        block_start=0,
+        chunk_start=0,
+    )
+    b2 = DataSetBlock(
+        data=dummy_block.data[2:, :, :],
+        aux_data=dummy_block.aux_data,
+        slicing_dim=0,
+        chunk_shape=dummy_block.chunk_shape,
+        global_shape=dummy_block.global_shape,
+        block_start=2,
+        chunk_start=0,
+    )
+    wrp.execute(b1)
+    wrp.execute(b2)
+
+    assert wrp.get_side_output() == {
+        "glob_stats": (
+            1.2,
+            3.1,
+            4.2,
+            20,
+        ),  # computes mean (sum/total), accumulates elements
+    }
+
+
+@pytest.mark.mpi
+@pytest.mark.skipif(
+    MPI.COMM_WORLD.size != 2, reason="Only rank-2 MPI is supported with this test"
+)
+def test_calculate_stats_2_processes(mocker: MockerFixture, dummy_block: DataSetBlock):
+    class FakeModule:
+        def calculate_stats(data, comm):
+            # outputs min/max/sum/total_elements
+            if comm.rank == 0:
+                return (1.2, 3.1, 42.0, 10)
+            else:
+                return (1.1, 3.5, 40.0, 9)
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker),
+        "mocked_module_path.calculate_stats",
+        "calculate_stats",
+        MPI.COMM_WORLD,
+        output_mapping={"glob_stats": "glob_stats"},
+    )
+    wrp.execute(dummy_block)
+
+    assert wrp.get_side_output() == {
+        "glob_stats": (1.1, 3.5, (42.0 + 40.0) / 19, 19),  # computes mean (sum/total)
+    }
+
+
+@pytest.mark.parametrize("gpu", [False, True], ids=["CPU-input", "GPU-input"])
+def test_calculate_stats_uses_gpu_if_available(
+    mocker: MockerFixture, dummy_block: DataSetBlock, gpu: bool
+):
+
+    if gpu and not gpu_enabled:
+        pytest.skip("No GPU available")
+
+    class FakeModule:
+        def calculate_stats(data, comm):
+            # regardless of dataset input, we want device data if gpu enabled
+            if gpu_enabled:
+                assert getattr(data, "device", None) is not None
+            else:
+                assert getattr(data, "device", None) is None
+            return (1.2, 3.1, 42.0, 10)
+
+    mocker.patch("importlib.import_module", return_value=FakeModule)
+    wrp = make_method_wrapper(
+        make_mock_repo(mocker),
+        "mocked_module_path.calculate_stats",
+        "calculate_stats",
+        MPI.COMM_SELF,
+        output_mapping={"glob_stats": "glob_stats"},
+    )
+
+    if gpu is True:
+        dummy_block.to_gpu()
+
+    res = wrp.execute(dummy_block)
+
+    assert res.is_gpu == gpu
diff --git a/tests/monitors/__init__.py b/tests/monitors/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/monitors/test_aggregate.py b/tests/monitors/test_aggregate.py
new file mode 100644
index 000000000..abea71fc5
--- /dev/null
+++ b/tests/monitors/test_aggregate.py
@@ -0,0 +1,95 @@
+from io import StringIO
+import pytest
+from pytest_mock import MockerFixture
+from httomo.monitors import make_monitors
+from httomo.monitors.aggregate import AggregateMonitoring
+
+from httomo.runner.monitoring_interface import MonitoringInterface
+
+
+def test_aggregate_passes_on_method(mocker: MockerFixture):
+    mon1 = mocker.create_autospec(MonitoringInterface, instance=True)
+    mon2 = mocker.create_autospec(MonitoringInterface, instance=True)
+    agg = AggregateMonitoring([mon1, mon2])
+    args = ("method", "module", "task", 0, (1, 2, 3), (0, 0, 0), (10, 0, 0), 
+            42.0, 3.2, 1.2, 1.1)
+    agg.report_method_block(*args)
+
+    mon1.report_method_block.assert_called_once_with(*args)
+    mon2.report_method_block.assert_called_once_with(*args)
+
+
+def test_aggregate_passes_on_source_block(mocker: MockerFixture):
+    mon1 = mocker.create_autospec(MonitoringInterface, instance=True)
+    mon2 = mocker.create_autospec(MonitoringInterface, instance=True)
+    agg = AggregateMonitoring([mon1, mon2])
+    args = ("loader", "task", 0, (1, 2, 3), (0, 0, 0), (10, 0, 0), 42.0)
+    agg.report_source_block(*args)
+
+    mon1.report_source_block.assert_called_once_with(*args)
+    mon2.report_source_block.assert_called_once_with(*args)
+
+
+def test_aggregate_passes_on_sink_block(mocker: MockerFixture):
+    mon1 = mocker.create_autospec(MonitoringInterface, instance=True)
+    mon2 = mocker.create_autospec(MonitoringInterface, instance=True)
+    agg = AggregateMonitoring([mon1, mon2])
+    args = ("save", "task", 0, (1, 2, 3), (0, 0, 0), (10, 0, 0), 42.0)
+    agg.report_sink_block(*args)
+
+    mon1.report_sink_block.assert_called_once_with(*args)
+    mon2.report_sink_block.assert_called_once_with(*args)
+
+
+def test_aggregate_passes_on_total_time(mocker: MockerFixture):
+    mon1 = mocker.create_autospec(MonitoringInterface, instance=True)
+    mon2 = mocker.create_autospec(MonitoringInterface, instance=True)
+    agg = AggregateMonitoring([mon1, mon2])
+    args = (42.0,)
+    agg.report_total_time(*args)
+
+    mon1.report_total_time.assert_called_once_with(*args)
+    mon2.report_total_time.assert_called_once_with(*args)
+
+
+def test_aggregate_passes_on_write_results(mocker: MockerFixture):
+    mon1 = mocker.create_autospec(MonitoringInterface, instance=True)
+    mon2 = mocker.create_autospec(MonitoringInterface, instance=True)
+    agg = AggregateMonitoring([mon1, mon2])
+    dest = StringIO()
+    args = (dest,)
+    agg.write_results(*args)
+
+    mon1.write_results.assert_called_once_with(*args)
+    mon2.write_results.assert_called_once_with(*args)
+
+
+def test_make_monitors_2(mocker: MockerFixture):
+    moncls1 = mocker.create_autospec(MonitoringInterface, instance=False)
+    moncls2 = mocker.create_autospec(MonitoringInterface, instance=False)
+    moncls3 = mocker.create_autospec(MonitoringInterface, instance=False)
+    mocker.patch(
+        "httomo.monitors.MONITORS_MAP", {"m1": moncls1, "m2": moncls2, "m3": moncls3}
+    )
+
+    mon = make_monitors(["m1", "m2"])
+    assert isinstance(mon, AggregateMonitoring)
+    assert len(mon._monitors) == 2
+    moncls1.assert_called_once()
+    moncls2.assert_called_once()
+    moncls3.assert_not_called()
+    assert isinstance(mon._monitors[0], type(moncls1()))
+    assert isinstance(mon._monitors[1], type(moncls2()))
+
+
+def test_make_monitors_unknown(mocker: MockerFixture):
+    mocker.patch("httomo.monitors.MONITORS_MAP", {})
+
+    with pytest.raises(ValueError) as e:
+        make_monitors(["m1"])
+
+    assert "Unknown monitor 'm1'" in str(e)
+
+
+def test_make_monitors_empty():
+    assert make_monitors([]) is None
\ No newline at end of file
diff --git a/tests/monitors/test_benchmark.py b/tests/monitors/test_benchmark.py
new file mode 100644
index 000000000..3fd8bc94d
--- /dev/null
+++ b/tests/monitors/test_benchmark.py
@@ -0,0 +1,108 @@
+from io import StringIO
+
+import pytest
+from mpi4py import MPI
+from httomo.monitors.benchmark import BenchmarkMonitoring
+
+
+def test_benchmark_monitor_records_and_displays_data():
+    mon = BenchmarkMonitoring()
+    mon.report_method_block(
+        "method1", "module", "task", 0, (1, 2, 3), (0, 0, 0), (10, 0, 0), 42.0, 2.0, 0.1, 0.2
+    )
+    mon.report_method_block(
+        "method2", "module", "task", 0, (1, 2, 3), (0, 0, 0), (10, 0, 0), 42.0, 2.0, 0.1, 0.2
+    )
+    mon.report_source_block(
+        "loader", "method1", 0, (1, 2, 3), (0, 0, 0), (10, 0, 0), 4.0
+    )
+    mon.report_source_block(
+        "other", "method2", 0, (1, 2, 3), (0, 0, 0), (10, 0, 0), 4.0
+    )
+    mon.report_sink_block("wrt1", "method1", 0, (1, 2, 3), (0, 0, 0), (10, 0, 0), 3.0)
+    mon.report_sink_block("dummy", "method2", 0, (1, 2, 3), (0, 0, 0), (10, 0, 0), 3.0)
+    mon.report_total_time(500.0)
+
+    dest = StringIO()
+    mon.write_results(dest)
+    dest.flush()
+    data = dest.getvalue().splitlines()
+    assert len(data) == 8
+    assert data[0] == ",".join([
+        "Type",
+        "Rank","Name",
+        "Task id",
+        "Module",
+        "Slicing dim",
+        "Block offset (chunk)",
+        "Block offset (global)",
+        "Block dim z",
+        "Block dim y",
+        "Block dim x",
+        "CPU time",
+        "GPU kernel time",
+        "GPU H2D time",
+        "GPU D2H time"
+    ])
+
+    assert "42.0,2.0,0.1,0.2" in data[1]
+    assert "42.0,2.0,0.1,0.2" in data[2]
+    assert "4.0,0.0" in data[3]
+    assert "4.0,0.0" in data[4]
+    assert "3.0,0.0" in data[5]
+    assert "3.0,0.0" in data[6]
+    assert "500.0,0.0" in data[7]
+
+
+@pytest.mark.mpi
+@pytest.mark.skipif(
+    MPI.COMM_WORLD.size != 2, reason="Only rank-2 MPI is supported with this test"
+)
+def test_summary_monitor_records_and_displays_data_mpi():
+    comm = MPI.COMM_WORLD
+    mon = BenchmarkMonitoring()
+    # everything gets reported twice - once in each process - and the write_results should aggregate
+    # in process 0
+    mon.report_method_block(
+        "method1", "module", "task", 0, (1, 2, 3), (0, 0, 0), (10, 0, 0), 42.0, 2.0, 0.1, 0.2
+    )
+    mon.report_source_block(
+        "loader", "method1", 0, (1, 2, 3), (0, 0, 0), (10, 0, 0), 4.0
+    )
+    mon.report_sink_block("wrt1", "method1", 0, (1, 2, 3), (0, 0, 0), (10, 0, 0), 3.0)
+    mon.report_total_time(500.0)
+
+    dest = StringIO()
+    mon.write_results(dest)
+    dest.flush()
+    data = dest.getvalue().splitlines()
+    if comm.rank == 1:
+        assert len(data) == 0
+    else:
+        assert len(data) == 9
+        assert data[0] == ",".join([
+            "Type",
+            "Rank","Name",
+            "Task id",
+            "Module",
+            "Slicing dim",
+            "Block offset (chunk)",
+            "Block offset (global)",
+            "Block dim z",
+            "Block dim y",
+            "Block dim x",
+            "CPU time",
+            "GPU kernel time",
+            "GPU H2D time",
+            "GPU D2H time"
+        ])
+
+        for rank in [0, 1]:
+            assert "42.0,2.0,0.1,0.2" in data[1+rank*4]
+            assert f"method,{rank},method1" in data[1+rank*4]
+            assert "4.0,0.0" in data[2+rank*4]
+            assert f"source,{rank},loader" in data[2+rank*4]
+            assert "3.0,0.0" in data[3+rank*4]
+            assert f"sink,{rank},wrt1" in data[3+rank*4]
+            assert "500.0,0.0" in data[4+rank*4]
+            assert f"total,{rank},," in data[4+rank*4]
diff --git a/tests/monitors/test_summary.py b/tests/monitors/test_summary.py
new file mode 100644
index 000000000..75c833346
--- /dev/null
+++ b/tests/monitors/test_summary.py
@@ -0,0 +1,101 @@
+from io import StringIO
+
+import pytest
+from mpi4py import MPI
+from httomo.monitors.summary import SummaryMonitor
+
+
+def test_summary_monitor_records_and_displays_data():
+    mon = SummaryMonitor()
+    mon.report_method_block(
+        "method1", "module", "task", 0, (1, 2, 3), (0, 0, 0), (10, 0, 0), 42.0, 2.0
+    )
+    mon.report_method_block(
+        "method2", "module", "task", 0, (1, 2, 3), (0, 0, 0), (10, 0, 0), 42.0, 2.0
+    )
+    mon.report_source_block(
+        "loader", "method1", 0, (1, 2, 3), (0, 0, 0), (10, 0, 0), 4.0
+    )
+    mon.report_source_block(
+        "other", "method2", 0, (1, 2, 3), (0, 0, 0), (10, 0, 0), 4.0
+    )
+    mon.report_sink_block("wrt1", "method1", 0, (1, 2, 3), (0, 0, 0), (10, 0, 0), 3.0)
+    mon.report_sink_block("dummy", "method2", 0, (1, 2, 3), (0, 0, 0), (10, 0, 0), 3.0)
+    mon.report_total_time(500.0)
+
+    dest = StringIO()
+    mon.write_results(dest)
+    dest.flush()
+    data = dest.getvalue()
+    
+    assert "across 1 process" in data
+    assert "Summary Statistics" in data
+    assert "methods CPU time" in data
+    assert " 84.0" in data
+    assert "methods GPU time" in data
+    assert " 4.0" in data
+    assert "sources time" in data
+    assert " 8.0" in data
+    assert "sinks time" in data
+    assert " 6.0" in data
+    assert "pipeline time" in data
+    assert "500.0" in data
+    assert "wall time" in data
+    assert "Method breakdowns" in data
+    assert "method1" in data
+    assert "method2" in data
+
+@pytest.mark.mpi
+@pytest.mark.skipif(
+    MPI.COMM_WORLD.size != 2, reason="Only rank-2 MPI is supported with this test"
+)
+def test_summary_monitor_records_and_displays_data_mpi():
+
+    comm = MPI.COMM_WORLD
+    mon = SummaryMonitor()
+    # everything gets reported twice - once in each process - and the write_results should aggregate
+    # in process 0
+    mon.report_method_block(
+        "method1", "module", "task", 0, (1, 2, 3), (0, 0, 0), (10, 0, 0), 42.0, 2.0, 0.1, 0.2
+    )
+    mon.report_method_block(
+        "method2", "module", "task", 0, (1, 2, 3), (0, 0, 0), (10, 0, 0), 42.0, 2.0, 0.1, 0.2
+    )
+    mon.report_source_block(
+        "loader", "method1", 0, (1, 2, 3), (0, 0, 0), (10, 0, 0), 4.0
+    )
+    mon.report_source_block(
+        "other", "method2", 0, (1, 2, 3), (0, 0, 0), (10, 0, 0), 4.0
+    )
+    mon.report_sink_block("wrt1", "method1", 0, (1, 2, 3), (0, 0, 0), (10, 0, 0), 3.0)
+    mon.report_sink_block("dummy", "method2", 0, (1, 2, 3), (0, 0, 0), (10, 0, 0), 3.0)
+    mon.report_total_time(500.0)
+
+    dest = StringIO()
+    mon.write_results(dest)
+    dest.flush()
+    data = dest.getvalue()
+    if comm.rank == 1:
+        assert data == ""
+    else:
+        assert "Summary Statistics" in data
+        assert "across 2 processes" in data
+        assert "methods CPU time" in data
+        assert " 84.0" in data
+        assert "methods GPU time" in data
+        assert " 8.0" in data
+        assert "host2device" in data
+        assert " 0.4" in data
+        assert "device2host" in data
+        assert " 0.8" in data
+        assert "sources time" in data
+        assert " 16.0" in data
+        assert "sinks time" in data
+        assert " 12.0" in data
+        assert "pipeline time" in data
+        assert "1000.0" in data
+        assert "wall time" in data
+        assert "500.0" in data
+        assert "Method breakdowns" in data
+        assert "method1" in data
+        assert "method2" in data
diff --git a/tests/runner/test_block_split.py b/tests/runner/test_block_split.py
index b4ea28b22..1bc8c23b2 100644
--- a/tests/runner/test_block_split.py
+++ b/tests/runner/test_block_split.py
@@ -1,300 +1,136 @@
-import numpy as np
+from unittest.mock import call
 import pytest
-from httomo.runner.block_split import BlockAggregator, BlockSplitter
-from httomo.runner.dataset import DataSet
-from httomo.utils import Pattern, gpu_enabled, xp
+from pytest_mock import MockerFixture
+from httomo.runner.block_split import BlockSplitter
+from httomo.runner.dataset_store_interfaces import DataSetSource
 
 
-def test_block_splitter_gives_full_when_fits(dummy_dataset: DataSet):
-    splitter = BlockSplitter(dummy_dataset, Pattern.projection, 100000000)
-
-    assert splitter.slices_per_block == dummy_dataset.data.shape[0]
-    assert len(splitter) == 1
-
-
-def test_block_splitter_splits_evenly(dummy_dataset: DataSet):
-    assert (
-        dummy_dataset.data.shape[0] % 2 == 0
-    ), "explicitly make sure dummy data is even"
-
-    splitter = BlockSplitter(
-        dummy_dataset, Pattern.projection, dummy_dataset.data.shape[0] // 2
+def test_block_splitter_throws_with_slicingdim_2(mocker: MockerFixture):
+    """This test should be removed if dim=2 is fully supported"""
+    source = mocker.create_autospec(
+        DataSetSource, chunk_shape=(10, 10, 10), slicing_dim=2
     )
+    with pytest.raises(AssertionError):
+        BlockSplitter(source, 100000000)
 
-    assert splitter.slices_per_block == dummy_dataset.data.shape[0] // 2
-    assert len(splitter) == 2
-
-
-def test_block_splitter_splits_odd(dummy_dataset: DataSet):
-    assert (
-        dummy_dataset.data.shape[0] % 3 != 0
-    ), "explicitly make sure dummy data is not"
 
-    splitter = BlockSplitter(
-        dummy_dataset, Pattern.projection, dummy_dataset.data.shape[0] // 3
+@pytest.mark.parametrize("slicing_dim", [0, 1], ids=["proj", "sino"])
+def test_block_splitter_gives_full_when_fits(mocker: MockerFixture, slicing_dim: int):
+    CHUNK_SHAPE = (100, 70, 100)
+    source = mocker.create_autospec(
+        DataSetSource, chunk_shape=CHUNK_SHAPE, slicing_dim=slicing_dim
     )
+    splitter = BlockSplitter(source, 100000000)
 
-    assert splitter.slices_per_block == dummy_dataset.data.shape[0] // 3
-    assert len(splitter) == 4
-
-
-def test_block_gives_dataset_full(dummy_dataset: DataSet):
-    splitter = BlockSplitter(dummy_dataset, Pattern.projection, 100000000)
-    assert splitter[0].data.shape == dummy_dataset.data.shape
-
-
-def test_block_gives_blocks_projection(dummy_dataset: DataSet):
-    dummy_dataset.data = np.random.random(dummy_dataset.data.shape).astype(np.float32)
+    assert splitter.slices_per_block == CHUNK_SHAPE[slicing_dim]
+    assert len(splitter) == 1
 
-    max_slices = dummy_dataset.data.shape[0] // 2
-    splitter = BlockSplitter(dummy_dataset, Pattern.projection, max_slices)
 
-    np.testing.assert_array_equal(
-        splitter[0].data, dummy_dataset.data[0:max_slices, :, :]
+@pytest.mark.parametrize("slicing_dim", [0, 1], ids=["proj", "sino"])
+def test_block_splitter_raises_if_out_of_bounds(
+    mocker: MockerFixture, slicing_dim: int
+):
+    CHUNK_SHAPE = (100, 70, 100)
+    source = mocker.create_autospec(
+        DataSetSource, chunk_shape=CHUNK_SHAPE, slicing_dim=slicing_dim
     )
-    np.testing.assert_array_equal(
-        splitter[1].data, dummy_dataset.data[max_slices:, :, :]
-    )
-
+    splitter = BlockSplitter(source, 100000000)
 
-def test_block_gives_blocks_sino(dummy_dataset: DataSet):
-    dummy_dataset.data = np.random.random(dummy_dataset.data.shape).astype(np.float32)
+    with pytest.raises(IndexError):
+        splitter[12]
 
-    max_slices = dummy_dataset.data.shape[1] // 2
-    splitter = BlockSplitter(dummy_dataset, Pattern.sinogram, max_slices)
 
-    np.testing.assert_array_equal(
-        splitter[0].data, dummy_dataset.data[:, 0:max_slices, :]
+@pytest.mark.parametrize("slicing_dim", [0, 1], ids=["proj", "sino"])
+def test_block_splitter_splits_evenly(mocker: MockerFixture, slicing_dim: int):
+    CHUNK_SHAPE = (100, 70, 100)
+    source = mocker.create_autospec(
+        DataSetSource, chunk_shape=CHUNK_SHAPE, slicing_dim=slicing_dim
     )
-    np.testing.assert_array_equal(
-        splitter[1].data, dummy_dataset.data[:, max_slices:, :]
-    )
-
+    splitter = BlockSplitter(source, CHUNK_SHAPE[slicing_dim] // 2)
 
-def test_block_gives_blocks_odd(dummy_dataset: DataSet):
-    dummy_dataset.data = np.random.random(dummy_dataset.data.shape).astype(np.float32)
+    assert splitter.slices_per_block == CHUNK_SHAPE[slicing_dim] // 2
+    assert len(splitter) == 2
 
-    max_slices = dummy_dataset.data.shape[0] // 3
-    splitter = BlockSplitter(dummy_dataset, Pattern.projection, max_slices)
 
-    np.testing.assert_array_equal(
-        splitter[0].data, dummy_dataset.data[0:max_slices, :, :]
-    )
-    np.testing.assert_array_equal(
-        splitter[1].data, dummy_dataset.data[max_slices : 2 * max_slices, :, :]
-    )
-    np.testing.assert_array_equal(
-        splitter[2].data, dummy_dataset.data[2 * max_slices : 3 * max_slices, :, :]
+@pytest.mark.parametrize("slicing_dim", [0, 1], ids=["proj", "sino"])
+def test_block_splitter_splits_odd(mocker: MockerFixture, slicing_dim: int):
+    CHUNK_SHAPE = (10, 7, 100)
+    source = mocker.create_autospec(
+        DataSetSource, chunk_shape=CHUNK_SHAPE, slicing_dim=slicing_dim
     )
-    np.testing.assert_array_equal(
-        splitter[3].data, dummy_dataset.data[3 * max_slices :, :, :]
-    )
-
-
-def test_block_can_iterate(dummy_dataset: DataSet):
-    dummy_dataset.data = np.random.random(dummy_dataset.data.shape).astype(np.float32)
-
-    max_slices = dummy_dataset.data.shape[1] // 2
-    splitter = BlockSplitter(dummy_dataset, Pattern.projection, max_slices)
-
-    for i, block in enumerate(splitter):
-        np.testing.assert_array_equal(
-            block.data, dummy_dataset.data[i * max_slices : (i + 1) * max_slices, :, :]
-        )
 
+    splitter = BlockSplitter(source, 3)
 
-def test_aggregator_throws_if_unfinished(dummy_dataset: DataSet):
-    aggregator = BlockAggregator(dummy_dataset, Pattern.projection)
-    with pytest.raises(ValueError) as e:
-        aggregator.full_dataset
+    assert splitter.slices_per_block == 3
+    if slicing_dim == 0:
+        assert len(splitter) == 4
+    else:
+        assert len(splitter) == 3
 
-    assert "not finished" in str(e)
 
-
-def test_aggregator_throws_if_partially_finished(dummy_dataset: DataSet):
-    splitter = BlockSplitter(
-        dummy_dataset, Pattern.projection, dummy_dataset.data.shape[0] // 2
+@pytest.mark.parametrize("slicing_dim", [0, 1], ids=["proj", "sino"])
+def test_block_gives_dataset_full(mocker: MockerFixture, slicing_dim: int):
+    CHUNK_SHAPE = (10, 7, 100)
+    source = mocker.create_autospec(
+        DataSetSource, chunk_shape=CHUNK_SHAPE, slicing_dim=slicing_dim
     )
-    aggregator = BlockAggregator(dummy_dataset, Pattern.projection)
-    aggregator.append(splitter[0])
-
-    with pytest.raises(ValueError) as e:
-        aggregator.full_dataset
-
-    assert "not finished" in str(e)
-
-
-def test_aggregator_throws_if_aggregate_too_much(dummy_dataset: DataSet):
-    aggregator = BlockAggregator(dummy_dataset, Pattern.projection)
-    aggregator.append(dummy_dataset)
+    splitter = BlockSplitter(source, 100000000)
 
-    with pytest.raises(ValueError) as e:
-        aggregator.append(dummy_dataset)
+    splitter[0]
 
-    assert "only 0 slices left" in str(e)
+    source.read_block.assert_called_with(0, CHUNK_SHAPE[slicing_dim])
 
 
-@pytest.mark.parametrize("max_slices", [1, 3, 5, 100000])
-def test_can_aggregate_full(dummy_dataset: DataSet, max_slices: int):
-    splitter = BlockSplitter(dummy_dataset, Pattern.projection, max_slices)
-    aggregator = BlockAggregator(dummy_dataset, Pattern.projection)
-
-    for block in splitter:
-        aggregator.append(block)
-
-    res = aggregator.full_dataset
+@pytest.mark.parametrize("slicing_dim", [0, 1], ids=["proj", "sino"])
+def test_block_reads_blocks_even(mocker: MockerFixture, slicing_dim: int):
+    CHUNK_SHAPE = (100, 70, 100)
+    source = mocker.create_autospec(
+        DataSetSource, chunk_shape=CHUNK_SHAPE, slicing_dim=slicing_dim
+    )
 
-    np.testing.assert_array_equal(res.data, dummy_dataset.data)
-    np.testing.assert_array_equal(res.darks, dummy_dataset.darks)
-    np.testing.assert_array_equal(res.flats, dummy_dataset.flats)
-    np.testing.assert_array_equal(res.angles, dummy_dataset.angles)
+    max_slices = CHUNK_SHAPE[slicing_dim] // 2
+    splitter = BlockSplitter(source, max_slices)
 
+    splitter[0]
+    splitter[1]
 
-def test_can_aggregate_changed_dimensions(dummy_dataset: DataSet):
-    splitter = BlockSplitter(
-        dummy_dataset, Pattern.projection, dummy_dataset.data.shape[0] // 2
+    source.read_block.assert_has_calls(
+        [call(0, max_slices), call(max_slices, max_slices)]
     )
-    aggregator = BlockAggregator(dummy_dataset, Pattern.projection)
 
-    for block in splitter:
-        shape = list(block.data.shape)
-        shape[1] += 3
-        shape[2] += 5
-        block.data = 2.0 * np.ones(tuple(shape), dtype=block.data.dtype)
-        aggregator.append(block)
 
-    res = aggregator.full_dataset
+@pytest.mark.parametrize("slicing_dim", [0, 1], ids=["proj", "sino"])
+def test_block_reads_blocks_odd(mocker: MockerFixture, slicing_dim: int):
+    CHUNK_SHAPE = (5, 7, 100)
+    source = mocker.create_autospec(
+        DataSetSource, chunk_shape=CHUNK_SHAPE, slicing_dim=slicing_dim
+    )
 
-    expected_shape = list(dummy_dataset.data.shape)
-    expected_shape[1] += 3
-    expected_shape[2] += 5
-    assert res.data.shape == tuple(expected_shape)
-    np.testing.assert_array_equal(res.data, 2.0)
+    max_slices = 4
+    splitter = BlockSplitter(source, max_slices)
 
+    splitter[0]
+    splitter[1]
 
-def test_changing_dimensions_in_second_block_fails(dummy_dataset: DataSet):
-    splitter = BlockSplitter(
-        dummy_dataset, Pattern.projection, dummy_dataset.data.shape[0] // 2
+    source.read_block.assert_has_calls(
+        [call(0, max_slices), call(max_slices, CHUNK_SHAPE[slicing_dim] - max_slices)]
     )
-    aggregator = BlockAggregator(dummy_dataset, Pattern.projection)
-    aggregator.append(splitter[0])
-
-    d = splitter[1]
-    shape = list(d.data.shape)
-    shape[1] += 3
-    shape[2] += 5
-    d.data = 2.0 * np.ones(tuple(shape), dtype=d.data.dtype)
-    with pytest.raises(ValueError) as e:
-        aggregator.append(d)
 
-    assert "different shape" in str(e)
 
-
-def test_can_aggregate_changed_datatype(dummy_dataset: DataSet):
-    dummy_dataset.data = dummy_dataset.data.astype(np.uint16)
-    splitter = BlockSplitter(
-        dummy_dataset, Pattern.projection, dummy_dataset.data.shape[0] // 2
-    )
-    aggregator = BlockAggregator(dummy_dataset, Pattern.projection)
-
-    for block in splitter:
-        block.data = block.data.astype(np.float32)
-        aggregator.append(block)
-
-    res = aggregator.full_dataset
-
-    assert res.data.dtype == np.float32
-    # this works because data is all '1' values, so cast doesn't change value
-    np.testing.assert_array_equal(res.data, dummy_dataset.data)
-
-
-@pytest.mark.skipif(
-    not gpu_enabled or xp.cuda.runtime.getDeviceCount() == 0,
-    reason="skipped as cupy is not available",
-)
-@pytest.mark.cupy
-def test_splitter_moves_to_cpu_if_not_already(dummy_dataset: DataSet):
-    dummy_dataset.to_gpu()
-    assert dummy_dataset.is_gpu
-    _ = BlockSplitter(dummy_dataset, Pattern.projection, dummy_dataset.data.shape[0])
-
-    assert dummy_dataset.is_cpu
-
-
-@pytest.mark.skipif(
-    not gpu_enabled or xp.cuda.runtime.getDeviceCount() == 0,
-    reason="skipped as cupy is not available",
-)
-@pytest.mark.cupy
-def test_aggregator_moves_to_cpu_if_not_already(dummy_dataset: DataSet):
-    dummy_dataset.to_gpu()
-    assert dummy_dataset.is_gpu
-    _ = BlockAggregator(dummy_dataset, Pattern.projection)
-
-    assert dummy_dataset.is_cpu
-
-
-@pytest.mark.parametrize("max_slices", [1, 3, 5, 100000])
-def test_preserves_global_slice_info(dummy_dataset: DataSet, max_slices: int):
-    dummy_dataset = DataSet(
-        data=dummy_dataset.data,
-        flats=dummy_dataset.flats,
-        darks=dummy_dataset.darks,
-        angles=dummy_dataset.angles,
-        global_index=(10, 0, 0),  # 10 slices from global start
-        global_shape=(  # global shape is 20 more slices than local
-            20 + dummy_dataset.shape[0],
-            dummy_dataset.shape[1],
-            dummy_dataset.shape[2],
-        ),
-    )
-    splitter = BlockSplitter(dummy_dataset, Pattern.projection, max_slices)
-    aggregator = BlockAggregator(dummy_dataset, Pattern.projection)
-
-    count = 10
-    for block in splitter:
-        assert block.shape[1:] == dummy_dataset.shape[1:]
-        assert block.shape[0] <= dummy_dataset.shape[0]
-        assert block.shape[0] <= max_slices
-        assert block.global_index == (count, 0, 0)
-        assert block.global_shape == dummy_dataset.global_shape
-        aggregator.append(block)
-        count += block.shape[0]
-
-    res = aggregator.full_dataset
-
-    assert res.global_shape == dummy_dataset.global_shape
-    assert res.global_index == (10, 0, 0)
-
-
-def test_preserves_global_slice_info_changed_dims(dummy_dataset: DataSet):
-    dummy_dataset = DataSet(
-        data=dummy_dataset.data,
-        flats=dummy_dataset.flats,
-        darks=dummy_dataset.darks,
-        angles=dummy_dataset.angles,
-        global_index=(10, 0, 0),  # 10 slices from global start
-        global_shape=(  # global shape is 20 more slices than local
-            20 + dummy_dataset.shape[0],
-            dummy_dataset.shape[1],
-            dummy_dataset.shape[2],
-        ),
+@pytest.mark.parametrize("slicing_dim", [0, 1], ids=["proj", "sino"])
+def test_block_can_iterate(mocker: MockerFixture, slicing_dim: int):
+    CHUNK_SHAPE = (100, 70, 100)
+    source = mocker.create_autospec(
+        DataSetSource, chunk_shape=CHUNK_SHAPE, slicing_dim=slicing_dim
     )
 
-    splitter = BlockSplitter(
-        dummy_dataset, Pattern.projection, dummy_dataset.data.shape[0] // 2
-    )
-    aggregator = BlockAggregator(dummy_dataset, Pattern.projection)
+    max_slices = CHUNK_SHAPE[slicing_dim] // 2
+    splitter = BlockSplitter(source, max_slices)
 
-    for block in splitter:
-        shape = list(block.data.shape)
-        shape[1] += 3
-        shape[2] += 5
-        block.data = 2.0 * np.ones(tuple(shape), dtype=block.data.dtype)
-        aggregator.append(block)
+    for i, _ in enumerate(splitter):
+        assert i < 2
 
-    res = aggregator.full_dataset
+    source.read_block.assert_has_calls(
+        [call(0, max_slices), call(max_slices, max_slices)]
+    )
 
-    assert res.global_shape == (dummy_dataset.global_shape[0], dummy_dataset.global_shape[1] + 3, dummy_dataset.global_shape[2] + 5)
-    assert res.global_index == (10, 0, 0)
-    
\ No newline at end of file
diff --git a/tests/runner/test_dataset.py b/tests/runner/test_dataset.py
deleted file mode 100644
index 463ef5007..000000000
--- a/tests/runner/test_dataset.py
+++ /dev/null
@@ -1,315 +0,0 @@
-import numpy as np
-import pytest
-from pytest_mock import MockerFixture
-from httomo.utils import xp, gpu_enabled
-from httomo.runner.dataset import DataSet
-
-
-@pytest.fixture
-def dataset():
-    data = np.ones((10, 10, 10), dtype=np.float32)
-    flats = 2 * np.ones((2, 10, 10), dtype=np.float32)
-    darks = 3 * np.ones((2, 10, 10), dtype=np.float32)
-    angles = 4 * np.ones((20,), dtype=np.float32)
-
-    return DataSet(data, angles, flats, darks)
-
-
-@pytest.fixture
-def disable_gpu(mocker: MockerFixture):
-    gpu_enabled = mocker.patch("httomo.runner.dataset.gpu_enabled", return_value=False)
-    gpu_enabled.__bool__.return_value = False
-    return gpu_enabled
-
-
-def test_works_with_numpy(dataset):
-    np.testing.assert_array_equal(dataset.data, 1)
-    np.testing.assert_array_equal(dataset.flats, 2)
-    np.testing.assert_array_equal(dataset.flat, 2)
-    np.testing.assert_array_equal(dataset.darks, 3)
-    np.testing.assert_array_equal(dataset.dark, 3)
-    np.testing.assert_array_equal(dataset.angles, 4)
-    np.testing.assert_array_equal(dataset.angles_radians, 4)
-    assert dataset.is_gpu is False
-    assert dataset.is_locked is True
-
-
-def test_can_get_shape_info(dataset):
-    assert dataset.global_shape == dataset.data.shape
-    assert dataset.global_index == (0, 0, 0)
-    assert dataset.shape == dataset.global_shape
-
-
-def test_chunked_shape(dataset):
-    d2 = DataSet(
-        data=dataset.data[2:5, :, :],
-        darks=dataset.darks,
-        flats=dataset.flats,
-        angles=dataset.angles,
-        global_shape=dataset.data.shape,
-        global_index=(2, 0, 0),
-    )
-
-    assert d2.global_shape == dataset.data.shape
-    assert d2.global_index == (2, 0, 0)
-    assert d2.shape == np.shape(dataset.data[2:5, :, :])
-
-
-def test_chunked_shape_invalid(dataset):
-    with pytest.raises(ValueError) as e:
-        DataSet(
-            data=dataset.data[2:5, :, :],
-            darks=dataset.darks,
-            flats=dataset.flats,
-            angles=dataset.angles,
-            global_shape=(2, 2, 2),
-        )
-    assert "(2, 2, 2) is incompatible" in str(e)
-
-
-def test_darks_not_writeable_numpy(dataset):
-    with pytest.raises(ValueError):
-        dataset.darks[1, 1] = 42.0
-
-
-def test_flats_not_writeable_numpy(dataset):
-    with pytest.raises(ValueError):
-        dataset.flats[1, 1] = 42.0
-
-
-def test_angels_not_writeable_numpy(dataset):
-    with pytest.raises(ValueError):
-        dataset.angles[1, 1] = 42.0
-
-
-def test_data_writeable_numpy(dataset):
-    dataset.data[1, 1, 1] = 42.0
-
-    assert dataset.data[1, 1, 1] == 42.0
-
-
-def test_raises_error_on_to_gpu_if_no_gpu_available(dataset: DataSet, disable_gpu):
-    with pytest.raises(ValueError) as e:
-        dataset.to_gpu()
-    assert "cannot transfer to GPU if not enabled" in str(e)
-
-
-@pytest.mark.skipif(not gpu_enabled, reason="skipped as cupy is not available")
-@pytest.mark.cupy
-def test_gpu_transfer_and_back(dataset):
-    with xp.cuda.Device(0):
-        dataset.to_gpu()
-
-        assert dataset.data.device.id == 0
-        assert dataset.is_gpu is True
-        assert dataset.flats.device.id == 0
-        assert dataset.darks.device.id == 0
-
-        dataset.to_cpu()
-
-        assert dataset.is_gpu is False
-
-
-@pytest.mark.skipif(not gpu_enabled, reason="skipped as cupy is not available")
-@pytest.mark.cupy
-def test_angles_stay_on_cpu(dataset):
-    with xp.cuda.Device(0):
-        dataset.to_gpu()
-
-        assert getattr(dataset.angles, "device", None) is None
-        assert getattr(dataset.angles_radians, "device", None) is None
-
-
-@pytest.mark.skipif(
-    not gpu_enabled or xp.cuda.runtime.getDeviceCount() < 2,
-    reason="skipped as cupy and 2 GPUs are not available",
-)
-@pytest.mark.cupy
-def test_gpu_wrong_device(dataset):
-    with xp.cuda.Device(0):
-        dataset.to_gpu()
-        assert dataset.flats.device.id == 0
-
-    with xp.cuda.Device(1):
-        with pytest.raises(AssertionError):
-            dataset.flats
-
-
-@pytest.mark.parametrize(
-    "field", ["darks", "flats", "angles", "angles_radians", "dark", "flat"]
-)
-def test_reset_field_while_locked(dataset, field):
-    with pytest.raises(ValueError):
-        setattr(dataset, field, 2 * getattr(dataset, field))
-
-
-@pytest.mark.skipif(not gpu_enabled, reason="skipped as cupy is not available")
-@pytest.mark.cupy
-@pytest.mark.parametrize("field", ["darks", "flats", "dark", "flat"])
-def test_reset_field_gpu(dataset, field):
-    original_value = getattr(dataset, field).ravel()[0]
-    with xp.cuda.Device(0):
-        dataset.to_gpu()
-        dataset.unlock()
-        setattr(
-            dataset, field, 2 * getattr(dataset, field)
-        )  # dataset.darks = 2 * dataset.darks
-        dataset.lock()
-
-        xp.testing.assert_array_equal(getattr(dataset, field), 2 * original_value)
-        assert dataset.is_gpu is True
-        assert getattr(dataset, field).device.id == 0
-
-    dataset.to_cpu()
-    np.testing.assert_array_equal(getattr(dataset, field), 2 * original_value)
-
-
-@pytest.mark.skipif(not gpu_enabled, reason="skipped as cupy is not available")
-@pytest.mark.cupy
-@pytest.mark.parametrize("field", ["darks", "flats", "dark", "flat"])
-def test_reset_field_on_cpu_with_cached_gpudata(dataset, field):
-    original_value = getattr(dataset, field).ravel()[0]
-    with xp.cuda.Device(0):
-        dataset.to_gpu()
-        data = getattr(dataset, field)  # make sure it's transferred
-        assert data.device.id == 0
-        dataset.to_cpu()
-
-    dataset.unlock()
-    setattr(dataset, field, 2 * getattr(dataset, field))
-    dataset.lock()
-    with xp.cuda.Device(0):
-        dataset.to_gpu()
-        xp.testing.assert_array_equal(getattr(dataset, field), 2 * original_value)
-        assert getattr(dataset, field).device.id == 0
-
-
-@pytest.mark.parametrize(
-    "field", ["darks", "flats", "angles", "angles_radians", "dark", "flat"]
-)
-@pytest.mark.parametrize("gpu", [True, False])
-def test_reset_field_numpy(dataset, field, gpu, request):
-    if not gpu:
-        request.getfixturevalue(
-            "disable_gpu"
-        )  # disables GPU conditional on gpu parameter
-    dataset.unlock()
-    original_value = getattr(dataset, field).ravel()[0]
-    setattr(dataset, field, 2 * getattr(dataset, field))
-    dataset.unlock()
-
-    np.testing.assert_array_equal(getattr(dataset, field), 2 * original_value)
-
-
-def test_attributes_array(dataset):
-    expected = set(
-        ["data", "flats", "darks", "angles", "angles_radians", "dark", "flat"]
-    )
-    actual = set(dir(dataset))
-    assert actual == expected
-
-
-@pytest.mark.parametrize(
-    "dim,start,length", [(0, 0, 3), (0, 3, 2), (1, 0, 4), (1, 3, 1)]
-)
-def test_can_slice_dataset_to_blocks(
-    dataset: DataSet, dim: int, start: int, length: int
-):
-    # assign increasing numbers
-    dataset.data[:] = np.arange(dataset.data.size, dtype=dataset.data.dtype).reshape(
-        dataset.data.shape
-    )
-    block = dataset.make_block(dim, start, length)
-
-    assert block.is_block is True
-    assert dataset.is_block is False
-    assert block.base.shape == dataset.shape
-    assert block.global_shape == dataset.shape
-    assert block.chunk_shape == dataset.shape
-    assert dataset.chunk_shape == dataset.shape
-    assert block.chunk_index == block.global_index
-    assert dataset.chunk_index == (0, 0, 0)
-    assert dataset.is_last_in_chunk is True
-    assert block.is_last_in_chunk is False
-    if dim == 0:
-        np.testing.assert_array_equal(
-            dataset.data[start : start + length, :, :], block.data
-        )
-        assert block.global_index == (start, 0, 0)
-    else:
-        np.testing.assert_array_equal(
-            dataset.data[:, start : start + length, :], block.data
-        )
-        assert block.global_index == (0, start, 0)
-    # make sure the other fields share the same memory address
-    assert (
-        block.darks.__array_interface__["data"]
-        == dataset.darks.__array_interface__["data"]
-    )
-    assert (
-        block.flats.__array_interface__["data"]
-        == dataset.flats.__array_interface__["data"]
-    )
-    assert (
-        block.angles.__array_interface__["data"]
-        == dataset.angles.__array_interface__["data"]
-    )
-
-
-def test_is_last_block_in_chunk_property(dataset: DataSet):
-    assert dataset.is_last_in_chunk is True
-    assert dataset.make_block(0, dataset.shape[0] - 2, 2).is_last_in_chunk is True
-    assert dataset.make_block(0, dataset.shape[0] - 4, 2).is_last_in_chunk is False
-
-
-def test_cannot_slice_a_block(dataset: DataSet):
-    block = dataset.make_block(0, 0, 2)
-    with pytest.raises(ValueError):
-        block.make_block(0, 0, 1)
-
-
-@pytest.mark.parametrize(
-    "field", ["darks", "flats", "angles", "angles_radians", "dark", "flat"]
-)
-def test_cannot_reset_darks_flats_angles_on_a_block(dataset: DataSet, field: str):
-    block = dataset.make_block(0, 0, 2)
-    block.unlock()
-    dataset.unlock()
-    with pytest.raises(ValueError):
-        setattr(block, field, np.ones((10, 10), dtype=np.float32))
-
-
-@pytest.mark.skipif(not gpu_enabled, reason="skipped as cupy is not available")
-def test_a_block_reuses_base_cached_gpu_fields(dataset: DataSet):
-    with xp.cuda.Device(0):
-        dataset.to_gpu()
-        darks_orig = dataset.darks
-        flats_orig = dataset.flats
-        dataset.to_cpu()
-
-        block = dataset.make_block(0, 1, 2)
-        block.to_gpu()
-        darks_new = block.darks
-        flats_new = block.flats
-
-        assert block.is_gpu is True
-        # check that they are on the right device
-        assert block.data.device.id == 0
-        assert block.flats.device.id == 0
-        assert block.darks.device.id == 0
-        # check that cupy arrays are pointing to the same GPU memory
-        assert darks_orig.data == darks_new.data
-        assert flats_orig.data == flats_new.data
-
-
-@pytest.mark.skipif(not gpu_enabled, reason="skipped as cupy is not available")
-def test_block_caches_in_base_on_gpu_access(dataset: DataSet):
-    with xp.cuda.Device(0):
-        block = dataset.make_block(0, 1, 2)
-        block.to_gpu()
-        darks_new = block.darks
-
-        dataset.to_gpu()
-        darks_orig = dataset.darks
-
-        assert darks_orig.data == darks_new.data
diff --git a/tests/runner/test_dataset_block.py b/tests/runner/test_dataset_block.py
new file mode 100644
index 000000000..b04036a09
--- /dev/null
+++ b/tests/runner/test_dataset_block.py
@@ -0,0 +1,622 @@
+import math
+from typing import Tuple
+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
+
+
+def test_full_block_for_global_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))
+
+    assert block.is_cpu is True
+    assert block.is_gpu is False
+    assert block.global_index == (0, 0, 0)
+    assert block.global_shape == (10, 10, 10)
+    assert block.chunk_index == (0, 0, 0)
+    assert block.chunk_shape == (10, 10, 10)
+    assert block.shape == (10, 10, 10)
+    assert block.is_last_in_chunk is True
+    assert block.slicing_dim == 0
+
+    np.testing.assert_array_equal(data, block.data)
+    np.testing.assert_array_equal(angles, block.angles)
+    np.testing.assert_array_equal(angles, block.angles_radians)
+    assert block.darks.shape == (0, 10, 10)
+    assert block.dark.shape == (0, 10, 10)
+    assert block.flats.shape == (0, 10, 10)
+    assert block.flat.shape == (0, 10, 10)
+    assert block.darks.dtype == data.dtype
+    assert block.flats.dtype == data.dtype
+    assert block.dark.dtype == data.dtype
+    assert block.flat.dtype == data.dtype
+
+
+@pytest.mark.parametrize("slicing_dim", [0, 1, 2])
+def test_full_block_for_chunked_data(slicing_dim: int):
+    data = np.ones((10, 10, 10), dtype=np.float32)
+    chunk_start = 10
+    global_shape_t = [10, 10, 10]
+    global_shape_t[slicing_dim] = 30
+    global_shape = make_3d_shape_from_shape(global_shape_t)
+    angles = np.linspace(0, math.pi, global_shape[0], dtype=np.float32)
+    block = DataSetBlock(
+        data=data,
+        aux_data=AuxiliaryData(angles=angles),
+        block_start=0,
+        chunk_start=chunk_start,
+        slicing_dim=slicing_dim,
+        global_shape=global_shape,
+    )
+
+    expected_global_index = [0, 0, 0]
+    expected_global_index[slicing_dim] = chunk_start
+    assert block.global_index == tuple(expected_global_index)
+    assert block.global_shape == global_shape
+    assert block.chunk_index == (0, 0, 0)
+    assert block.chunk_shape == (
+        10,
+        10,
+        10,
+    )  # assume that block spans whole chunk if not given
+    assert block.shape == (10, 10, 10)
+    assert block.is_last_in_chunk is True
+    assert block.slicing_dim == slicing_dim
+
+
+@pytest.mark.parametrize("slicing_dim", [0, 1, 2])
+@pytest.mark.parametrize("last_in_chunk", [False, True], ids=["middle", "last"])
+def test_partial_block_for_chunked_data(slicing_dim: int, last_in_chunk: bool):
+    block_shape = [10, 10, 10]
+    block_shape[slicing_dim] = 2
+    start_index = 3 if not last_in_chunk else 8
+    data = np.ones(block_shape, dtype=np.float32)
+    global_index = [0, 0, 0]
+    global_index[slicing_dim] = 10 + start_index
+    global_shape_t = [10, 10, 10]
+    global_shape_t[slicing_dim] = 30
+    global_shape = make_3d_shape_from_shape(global_shape_t)
+    angles = np.linspace(0, math.pi, global_shape[0], dtype=np.float32)
+    block = DataSetBlock(
+        data=data,
+        aux_data=AuxiliaryData(angles=angles),
+        slicing_dim=slicing_dim,
+        block_start=start_index,
+        chunk_start=10,
+        global_shape=global_shape,
+        chunk_shape=(10, 10, 10),
+    )
+
+    assert block.global_index == tuple(global_index)
+    assert block.global_shape == global_shape
+    expected_chunk_index = [0, 0, 0]
+    expected_chunk_index[slicing_dim] = start_index
+    assert block.chunk_index == tuple(expected_chunk_index)
+    assert block.chunk_shape == (10, 10, 10)
+    assert block.shape == tuple(block_shape)
+    assert block.is_last_in_chunk is last_in_chunk
+    assert block.slicing_dim == slicing_dim
+
+
+# block_shape <= chunk_shape
+@pytest.mark.parametrize("block_shape", [(11, 10, 10), (10, 11, 10), (10, 10, 11)])
+def test_inconsistent_block_shape(block_shape: Tuple[int, int, int]):
+    data = np.ones(block_shape, dtype=np.float32)
+    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
+    with pytest.raises(ValueError):
+        DataSetBlock(
+            data,
+            aux_data=AuxiliaryData(angles=angles),
+            slicing_dim=0,
+            block_start=0,
+            chunk_start=0,
+            global_shape=(10, 10, 10),
+            chunk_shape=(10, 10, 10),
+        )
+
+
+@pytest.mark.parametrize(
+    "block_shape, slicing_dim",
+    [
+        ((10, 5, 10), 0),
+        ((10, 10, 5), 0),
+        ((10, 5, 5), 0),
+        ((5, 10, 10), 1),
+        ((10, 10, 5), 1),
+        ((5, 10, 5), 1),
+        ((5, 10, 10), 2),
+        ((10, 5, 10), 2),
+        ((5, 5, 10), 2),
+    ],
+    ids=[
+        "slice_dim_0_dim_1",
+        "slice_dim_0_dim_2",
+        "slice_dim_0_dim_1_2",
+        "slice_dim_1_dim_0",
+        "slice_dim_1_dim_2",
+        "slice_dim_1_dim_0_2",
+        "slice_dim_2_dim_0",
+        "slice_dim_2_dim_1",
+        "slice_dim_2_dim_0_1",
+    ],
+)
+def test_inconsistent_non_slicing_dims_block_global(
+    block_shape: Tuple[int, int, int], slicing_dim: int
+):
+    data = np.ones(block_shape, dtype=np.float32)
+    angles = np.linspace(0, math.pi, block_shape[0], dtype=np.float32)
+    with pytest.raises(ValueError):
+        DataSetBlock(
+            data,
+            aux_data=AuxiliaryData(angles=angles),
+            slicing_dim=slicing_dim,
+            block_start=0,
+            chunk_start=0,
+            global_shape=(10, 10, 10),
+            chunk_shape=(10, 10, 10),
+        )
+
+
+# global and chunk shape in non-slicing dim must be the same
+@pytest.mark.parametrize("slicing_dim", [0, 1, 2])
+def test_inconsistent_global_chunk_shape_non_slice_dim(slicing_dim: int):
+    data = np.ones((10, 10, 10), dtype=np.float32)
+    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
+    global_shape = [12, 12, 12]
+    global_shape[slicing_dim] = 10
+    with pytest.raises(ValueError):
+        DataSetBlock(
+            data,
+            aux_data=AuxiliaryData(angles=angles),
+            slicing_dim=slicing_dim,
+            block_start=0,
+            chunk_start=0,
+            global_shape=make_3d_shape_from_shape(global_shape),
+            chunk_shape=(10, 10, 10),
+        )
+
+
+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",
+    [-1, 10, 50],
+)
+def test_inconsistent_block_start(block_start: int):
+    block_shape = [10, 10, 10]
+    data = np.ones(block_shape, dtype=np.float32)
+    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
+    with pytest.raises(ValueError):
+        DataSetBlock(
+            data,
+            aux_data=AuxiliaryData(angles=angles),
+            slicing_dim=0,
+            block_start=block_start,
+            chunk_start=0,
+            global_shape=(10, 10, 10),
+            chunk_shape=(10, 10, 10),
+        )
+
+
+# chunk_index + block_shape not exceed chunk_shape (proxied by start + slicing dim)
+def test_inconsistent_block_span():
+    block_shape = [10, 10, 10]
+    data = np.ones(block_shape, dtype=np.float32)
+    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
+    with pytest.raises(ValueError):
+        DataSetBlock(
+            data,
+            aux_data=AuxiliaryData(angles=angles),
+            slicing_dim=0,
+            block_start=1,
+            chunk_start=0,
+            global_shape=(10, 10, 10),
+            chunk_shape=(10, 10, 10),
+        )
+
+
+# chunk_shape <= global_shape
+@pytest.mark.parametrize("chunk_shape", [(11, 10, 10), (10, 11, 10), (10, 10, 11)])
+def test_inconsistent_chunk_shape(chunk_shape: Tuple[int, int, int]):
+    block_shape = [10, 10, 10]
+    data = np.ones(block_shape, dtype=np.float32)
+    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
+    with pytest.raises(ValueError):
+        DataSetBlock(
+            data,
+            aux_data=AuxiliaryData(angles=angles),
+            slicing_dim=0,
+            block_start=0,
+            chunk_start=0,
+            global_shape=(10, 10, 10),
+            chunk_shape=(11, 10, 10),
+        )
+
+
+# global_index + chunk_shape not exceed global_shape
+def test_inconsistent_chunk_span():
+    block_shape = [10, 10, 10]
+    data = np.ones(block_shape, dtype=np.float32)
+    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
+    with pytest.raises(ValueError):
+        DataSetBlock(
+            data,
+            aux_data=AuxiliaryData(angles=angles),
+            slicing_dim=0,
+            block_start=0,
+            chunk_start=1,
+            global_shape=(10, 10, 10),
+            chunk_shape=(10, 10, 10),
+        )
+
+
+# global_index outside of global_shape
+@pytest.mark.parametrize(
+    "chunk_start",
+    [-1, 10, 50],
+)
+def test_inconsistent_chunk_start(chunk_start: int):
+    block_shape = [10, 10, 10]
+    data = np.ones(block_shape, dtype=np.float32)
+    angles = np.linspace(0, math.pi, 10, dtype=np.float32)
+    with pytest.raises(ValueError):
+        DataSetBlock(
+            data,
+            aux_data=AuxiliaryData(angles=angles),
+            slicing_dim=0,
+            block_start=0,
+            chunk_start=chunk_start,
+            global_shape=(10, 10, 10),
+            chunk_shape=(10, 10, 10),
+        )
+
+
+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 = DataSetBlock(
+        data=data,
+        aux_data=AuxiliaryData(angles=angles),
+        block_start=0,
+        chunk_start=10,
+        slicing_dim=0,
+        global_shape=(30, 10, 10),
+    )
+
+    block.data = 2 * data
+
+    np.testing.assert_array_equal(block.data, 2 * data)
+
+
+def test_modify_data_shape_change():
+    block_start = 2
+    chunk_start = 10
+    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 = DataSetBlock(
+        data=data,
+        aux_data=AuxiliaryData(angles=angles),
+        block_start=block_start,
+        chunk_start=chunk_start,
+        slicing_dim=0,
+        global_shape=global_shape,
+        chunk_shape=(10, 10, 10),
+    )
+
+    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)
+    assert block.global_shape == (global_shape[0], 30, 20)
+    assert block.chunk_shape == (10, 30, 20)
+    assert block.chunk_index == (block_start, 0, 0)
+    assert block.global_index == (chunk_start + block_start, 0, 0)
+
+
+@pytest.mark.parametrize("new_size", [3, 7])
+def test_modify_data_shape_change_in_slicing_dim_fails(new_size: int):
+    block_start = 2
+    chunk_start = 10
+    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 = DataSetBlock(
+        data=data,
+        aux_data=AuxiliaryData(angles=angles),
+        block_start=block_start,
+        chunk_start=chunk_start,
+        slicing_dim=0,
+        global_shape=global_shape,
+        chunk_shape=(10, 10, 10),
+    )
+
+    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/runner/test_dataset_store_interfaces.py b/tests/runner/test_dataset_store_interfaces.py
new file mode 100644
index 000000000..63491f4b4
--- /dev/null
+++ b/tests/runner/test_dataset_store_interfaces.py
@@ -0,0 +1,12 @@
+from httomo.runner.dataset import DataSetBlock
+from httomo.runner.dataset_store_interfaces import DummySink
+
+
+def test_dummy_dataset_sink(dummy_block: DataSetBlock):
+    dummy_sink = DummySink(0)
+    dummy_sink.write_block(dummy_block)
+    
+    assert dummy_sink.global_shape == dummy_block.global_shape
+    assert dummy_sink.global_index == dummy_block.chunk_index
+    assert dummy_sink.chunk_shape == dummy_block.chunk_shape
+    
\ No newline at end of file
diff --git a/tests/runner/test_loader.py b/tests/runner/test_loader.py
deleted file mode 100644
index f2b8437a2..000000000
--- a/tests/runner/test_loader.py
+++ /dev/null
@@ -1,60 +0,0 @@
-import os
-from typing import Union
-from mpi4py import MPI
-import pytest
-from pytest_mock import MockerFixture
-import numpy as np
-
-from httomo.data.hdf.loaders import LoaderData
-from httomo.runner.dataset import DataSet
-from httomo.runner.loader import make_loader
-from .testing_utils import make_mock_repo
-
-
-def test_loader_execute_raises_exception(mocker: MockerFixture, dummy_dataset: DataSet):
-    class FakeModule:
-        def fake_loader(name) -> LoaderData:
-            return LoaderData()
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    loader = make_loader(
-        make_mock_repo(mocker), "mocked_module_path", "fake_loader", MPI.COMM_WORLD
-    )
-    with pytest.raises(NotImplementedError):
-        loader.execute(dummy_dataset)
-
-
-def test_loader_load_produces_dataset(mocker: MockerFixture):
-    class FakeModule:
-        def fake_loader(name, in_file: Union[os.PathLike, str]) -> LoaderData:
-            assert name == "dataset"
-            assert in_file == "some_test_file"
-            return LoaderData(
-                data=np.ones((10, 10, 10), dtype=np.float32),
-                flats=2 * np.ones((2, 10, 10), dtype=np.float32),
-                darks=3 * np.ones((2, 10, 10), dtype=np.float32),
-                angles=4 * np.ones((10,), dtype=np.float32),
-                angles_total=10,
-                detector_x=5,
-                detector_y=14,
-            )
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    loader = make_loader(
-        make_mock_repo(mocker),
-        "mocked_module_path",
-        "fake_loader",
-        MPI.COMM_WORLD,
-        name="dataset",
-        in_file="some_test_file",
-    )
-    dataset = loader.load()
-
-    assert loader.detector_x == 5
-    assert loader.detector_y == 14
-    assert dataset.global_index == (0, 0, 0)
-    assert dataset.global_shape == (10, 10, 10)
-    np.testing.assert_array_equal(dataset.data, 1.0)
-    np.testing.assert_array_equal(dataset.flats, 2.0)
-    np.testing.assert_array_equal(dataset.darks, 3.0)
-    np.testing.assert_array_equal(dataset.angles, 4.0)
diff --git a/tests/runner/test_output_ref.py b/tests/runner/test_output_ref.py
index fa2607faf..decc38c6c 100644
--- a/tests/runner/test_output_ref.py
+++ b/tests/runner/test_output_ref.py
@@ -3,7 +3,7 @@
 
 from httomo.runner.output_ref import OutputRef
 
-from .testing_utils import make_test_method
+from ..testing_utils import make_test_method
 
 
 def test_gets_mapped_value(mocker: MockerFixture):
diff --git a/tests/runner/test_pipeline.py b/tests/runner/test_pipeline.py
index f0fec6227..ae3bd3c8a 100644
--- a/tests/runner/test_pipeline.py
+++ b/tests/runner/test_pipeline.py
@@ -2,28 +2,23 @@
 
 from httomo.runner.pipeline import Pipeline
 from httomo.utils import Pattern
-from .testing_utils import make_test_loader, make_test_method
+from ..testing_utils import make_test_loader, make_test_method
 
 
 def test_pipeline_get_loader_properties(mocker: MockerFixture):
     loader = make_test_loader(mocker, pattern=Pattern.projection)
-    loader.reslice = True
     p = Pipeline(loader=loader, methods=[])
 
     assert p.loader_pattern == loader.pattern
-    assert p.loader_reslice == loader.reslice
 
 
 def test_pipeline_set_loader_properties(mocker: MockerFixture):
     loader = make_test_loader(mocker, pattern=Pattern.projection)
-    loader.reslice = True
     p = Pipeline(loader=loader, methods=[])
 
     p.loader_pattern = Pattern.sinogram
-    p.loader_reslice = False
 
     assert loader.pattern == Pattern.sinogram
-    assert loader.reslice is False
 
 
 def test_pipeline_can_iterate(mocker: MockerFixture):
@@ -39,3 +34,17 @@ def test_pipeline_can_iterate(mocker: MockerFixture):
     assert len(p) == 3
     for i, m in enumerate(p):
         assert m.method_name == f"m{i+1}"
+
+
+def test_pipeline_can_access_by_index(mocker: MockerFixture):
+    p = Pipeline(
+        loader=make_test_loader(mocker, pattern=Pattern.projection),
+        methods=[
+            make_test_method(mocker, method_name="m1"),
+            make_test_method(mocker, method_name="m2"),
+            make_test_method(mocker, method_name="m3"),
+        ],
+    )
+    
+    for i in range(3):
+        assert p[i].method_name == f"m{i+1}"
diff --git a/tests/runner/test_platform_section.py b/tests/runner/test_platform_section.py
deleted file mode 100644
index fc2d84e62..000000000
--- a/tests/runner/test_platform_section.py
+++ /dev/null
@@ -1,310 +0,0 @@
-import pytest
-from pytest_mock import MockerFixture
-from httomo.runner.pipeline import Pipeline
-from httomo.runner.platform_section import sectionize, PlatformSection
-from httomo.utils import Pattern
-from .testing_utils import make_test_loader, make_test_method
-
-
-def test_determine_single_method(mocker: MockerFixture):
-    p = Pipeline(
-        loader=make_test_loader(mocker),
-        methods=[make_test_method(mocker, method_name="testmethod")],
-        save_results_set=[False],
-    )
-    s = sectionize(p, False)
-    assert len(s) == 1
-    assert s[0].methods[0].method_name == "testmethod"
-
-
-def test_sectionizer_can_iterate_saveall(mocker: MockerFixture):
-    p = Pipeline(
-        loader=make_test_loader(mocker),
-        methods=[
-            make_test_method(mocker, method_name=f"testmethod{i}") for i in range(3)
-        ],
-        save_results_set=[False, False, False],
-    )
-
-    s = sectionize(p, True)
-    assert len(s) == 3
-    assert [k.save_result for k in s] == [True, True, True] 
-    methodnames = [m.methods[0].method_name for m in s]
-    assert methodnames == ["testmethod0", "testmethod1", "testmethod2"]
-
-
-def test_platformsection_can_iterate(mocker: MockerFixture):
-    sec = PlatformSection(
-        True,
-        Pattern.projection,
-        False,
-        0,
-        [
-            make_test_method(mocker, method_name="test1"),
-            make_test_method(mocker, method_name="test2"),
-        ],
-    )
-
-    assert len(sec) == 2
-    for i, m in enumerate(sec):
-        assert m.method_name == f"test{i+1}"
-
-
-def test_sectionizer_two_cpu(mocker: MockerFixture):
-    p = Pipeline(
-        loader=make_test_loader(mocker),
-        methods=[
-            make_test_method(mocker, pattern=Pattern.projection),
-            make_test_method(mocker, pattern=Pattern.projection),
-        ],
-        save_results_set=[False, False],
-    )
-    s = sectionize(p, False)
-    assert len(s) == 1
-    s0 = s[0]
-    assert len(s0) == 2
-    assert s0.pattern == Pattern.projection
-
-
-def test_sectionizer_pattern_change(mocker: MockerFixture):
-    p = Pipeline(
-        loader=make_test_loader(mocker),
-        methods=[
-            make_test_method(mocker, pattern=Pattern.projection),
-            make_test_method(mocker, pattern=Pattern.sinogram),
-        ],
-        save_results_set=[False, False],
-    )
-    s = sectionize(p, False)
-    assert len(s) == 2
-    s0 = s[0]
-    assert len(s0) == 1
-    assert s0.pattern == Pattern.projection
-    s1 = s[1]
-    assert len(s1) == 1
-    assert s1.pattern == Pattern.sinogram
-
-
-def test_sectionizer_platform_change(mocker: MockerFixture):
-    p = Pipeline(
-        loader=make_test_loader(mocker),
-        methods=[
-            make_test_method(mocker, gpu=True),
-            make_test_method(mocker, gpu=False),
-        ],
-        save_results_set=[False, False],
-    )
-
-    s = sectionize(p, False)
-    assert len(s) == 2
-    s0 = s[0]
-    assert len(s0) == 1
-    assert s0.gpu is True
-    s1 = s[1]
-    assert len(s1) == 1
-    assert s1.gpu is False
-
-
-@pytest.mark.parametrize(
-    "loader_pattern, pattern1, pattern2, expected",
-    [
-        (Pattern.all, Pattern.projection, Pattern.all, Pattern.projection),
-        (Pattern.all, Pattern.all, Pattern.projection, Pattern.projection),
-        (Pattern.all, Pattern.sinogram, Pattern.all, Pattern.sinogram),
-        (Pattern.all, Pattern.all, Pattern.sinogram, Pattern.sinogram),
-        (Pattern.all, Pattern.all, Pattern.all, Pattern.projection),
-    ],
-    ids=[
-        "proj-all-proj",
-        "all-proj-proj",
-        "sino-all-sino",
-        "all-sino-sino",
-        "all-all-all",
-    ],
-)
-def test_determine_platform_sections_pattern_all_combine(
-    mocker: MockerFixture,
-    loader_pattern: Pattern,
-    pattern1: Pattern,
-    pattern2: Pattern,
-    expected: Pattern,
-):
-    loader = make_test_loader(mocker, loader_pattern)
-    p = Pipeline(
-        loader=loader,
-        methods=[
-            make_test_method(mocker, pattern=pattern1),
-            make_test_method(mocker, pattern=pattern2),
-        ],
-        save_results_set=[False, False]
-
-    )
-
-    s = sectionize(p, False)
-    assert len(s) == 1
-    s0 = s[0]
-    assert s0.gpu is False
-    assert len(s0) == 2
-    assert s0.pattern == expected
-    assert s0[0].pattern == expected
-    assert s0[1].pattern == expected
-    assert loader.pattern == expected
-
-
-def test_sectionizer_save_result_triggers_new_section(mocker: MockerFixture):
-    p = Pipeline(
-        loader=make_test_loader(mocker),
-        methods=[
-            make_test_method(mocker, pattern=Pattern.projection),
-            make_test_method(mocker, pattern=Pattern.projection),
-            make_test_method(mocker, pattern=Pattern.projection),
-            make_test_method(mocker, pattern=Pattern.projection),
-        ],
-        save_results_set=[True, False, True, False],
-    )
-
-    s = sectionize(p, False)
-    assert len(s) == 3
-    assert len(s[0]) == 1
-    assert s[0].save_result is True
-    assert len(s[1]) == 2
-    assert s[1].save_result is True
-    assert len(s[2]) == 1
-    assert s[2].save_result is False
-
-
-def test_sectionizer_global_stats_triggers_new_section(mocker: MockerFixture):
-    p = Pipeline(
-        loader=make_test_loader(mocker),
-        methods=[
-            make_test_method(mocker, pattern=Pattern.projection, glob_stats=True, method_name="m1"),
-            make_test_method(mocker, pattern=Pattern.projection, method_name="m2"),
-            make_test_method(mocker, pattern=Pattern.projection, glob_stats=True, method_name="m3"),
-            make_test_method(mocker, pattern=Pattern.projection, method_name="m4"),
-        ],
-        save_results_set=[False, False, False, False],
-    )
-
-    s = sectionize(p, False)
-    assert len(s) == 2
-    assert len(s[0]) == 2
-    assert len(s[1]) == 2
-    assert s[0][0].method_name == "m1"
-    assert s[1][0].method_name == "m3"
-
-
-@pytest.mark.parametrize(
-    "pattern1,pattern2,needs_reslice",
-    [
-        (Pattern.projection, Pattern.projection, False),
-        (Pattern.projection, Pattern.all, False),
-        (Pattern.all, Pattern.projection, False),
-        (Pattern.sinogram, Pattern.sinogram, False),
-        (Pattern.sinogram, Pattern.all, False),
-        (Pattern.all, Pattern.sinogram, False),
-        (Pattern.projection, Pattern.sinogram, True),
-        (Pattern.sinogram, Pattern.projection, True),
-    ],
-    ids=[
-        "proj-proj",
-        "proj-all",
-        "all-proj",
-        "sino-sino",
-        "sino-all",
-        "all-sino",
-        "proj-sino",
-        "sino-proj",
-    ],
-)
-def test_sectionizer_needs_reslice(
-    mocker: MockerFixture, pattern1: Pattern, pattern2: Pattern, needs_reslice: bool
-):
-    loader = make_test_loader(mocker)
-    p = Pipeline(
-        loader=loader,
-        methods=[
-            make_test_method(mocker, pattern=pattern1, gpu=True),
-            make_test_method(mocker, pattern=pattern2, gpu=False),
-        ],
-        save_results_set=[False, False],
-    )
-
-    s = sectionize(p, False)
-    assert len(s) == 2
-    assert s[0].reslice == needs_reslice
-    assert s[1].reslice is False
-    assert loader.pattern == s[0].pattern
-
-
-@pytest.mark.parametrize(
-    "pattern",
-    [Pattern.projection, Pattern.sinogram, Pattern.all],
-    ids=["proj", "sino", "all"],
-)
-def test_sectionizer_inherits_pattern_from_before_if_all(
-    mocker: MockerFixture, pattern: Pattern
-):
-    loader = make_test_loader(mocker, Pattern.projection)
-    p = Pipeline(
-        loader=loader,
-        methods=[
-            make_test_method(mocker, pattern=pattern, gpu=True),
-            make_test_method(mocker, pattern=Pattern.all, gpu=False),
-        ],
-        save_results_set=[False, False],
-    )
-
-    s = sectionize(p, False)
-    assert len(s) == 2
-    assert s[0].reslice is False
-    assert s[1].reslice is False
-    assert s[1].pattern == Pattern.projection if pattern == Pattern.all else pattern
-
-
-@pytest.mark.parametrize("loader_pattern", [Pattern.projection, Pattern.sinogram])
-def test_sectionizer_inherits_loader_pattern(
-    mocker: MockerFixture, loader_pattern: Pattern
-):
-    p = Pipeline(
-        loader=make_test_loader(mocker, pattern=loader_pattern),
-        methods=[make_test_method(mocker, pattern=Pattern.all, gpu=True)],
-        save_results_set=[False],
-    )
-
-    s = sectionize(p, False)
-    assert len(s) == 1
-    assert s[0].reslice is False
-    assert s[0].pattern == loader_pattern
-
-
-def test_sectionizer_sets_reslice_in_loader(mocker: MockerFixture):
-    loader = make_test_loader(mocker, pattern=Pattern.sinogram)
-    p = Pipeline(
-        loader=loader,
-        methods=[make_test_method(mocker, pattern=Pattern.projection, gpu=True)],
-        save_results_set=[False],
-    )
-
-    s = sectionize(p, False)
-    assert len(s) == 1
-    assert s[0].reslice is False
-    assert loader.pattern == Pattern.sinogram
-    assert loader.reslice is True
-
-
-def test_sectionizer_preprocess_in_own_section(mocker: MockerFixture):
-    loader = make_test_loader(mocker)
-    p = Pipeline(
-        loader=loader,
-        methods=[
-            make_test_method(mocker, pattern=Pattern.projection),
-            make_test_method(mocker, pattern=Pattern.projection),
-        ],
-        save_results_set=[False, False],
-        main_pipeline_start=1,
-    )
-
-    s = sectionize(p, False)
-    assert len(s) == 2
-    assert s[0].reslice is False
-    assert s[1].reslice is False
diff --git a/tests/runner/test_section.py b/tests/runner/test_section.py
new file mode 100644
index 000000000..960b4185d
--- /dev/null
+++ b/tests/runner/test_section.py
@@ -0,0 +1,275 @@
+from typing import Tuple
+import pytest
+from pytest_mock import MockerFixture
+from httomo.runner.output_ref import OutputRef
+from httomo.runner.pipeline import Pipeline
+from httomo.runner.section import sectionize, Section
+from httomo.utils import Pattern
+from ..testing_utils import make_test_loader, make_test_method
+
+# For reference, we break into new platform sections if and only if:
+# - the pattern was changed (reslice)
+# - an output is referenced from an earlier method (so it needs to have
+#   finished with all blocks before the side output can be read)
+
+
+def test_determine_single_method(mocker: MockerFixture):
+    p = Pipeline(
+        loader=make_test_loader(mocker),
+        methods=[make_test_method(mocker, method_name="testmethod")],
+    )
+    s = sectionize(p)
+    assert len(s) == 1
+    assert s[0].methods[0].method_name == "testmethod"
+
+
+def test_platformsection_can_iterate(mocker: MockerFixture):
+    sec = Section(
+        pattern=Pattern.projection,
+        max_slices=0,
+        methods=[
+            make_test_method(mocker, method_name="test1"),
+            make_test_method(mocker, method_name="test2"),
+        ],
+    )
+
+    assert len(sec) == 2
+    for i, m in enumerate(sec):
+        assert m.method_name == f"test{i+1}"
+
+
+def test_sectionizer_same_pattern(mocker: MockerFixture):
+    p = Pipeline(
+        loader=make_test_loader(mocker),
+        methods=[
+            make_test_method(mocker, pattern=Pattern.projection),
+            make_test_method(mocker, pattern=Pattern.projection),
+        ],
+    )
+    s = sectionize(p)
+    assert len(s) == 1
+    s0 = s[0]
+    assert len(s0) == 2
+    assert s0.pattern == Pattern.projection
+
+
+def test_sectionizer_pattern_change(mocker: MockerFixture):
+    p = Pipeline(
+        loader=make_test_loader(mocker),
+        methods=[
+            make_test_method(mocker, pattern=Pattern.projection),
+            make_test_method(mocker, pattern=Pattern.sinogram),
+        ],
+    )
+    s = sectionize(p)
+    assert len(s) == 2
+    s0 = s[0]
+    assert len(s0) == 1
+    assert s0.pattern == Pattern.projection
+    s1 = s[1]
+    assert len(s1) == 1
+    assert s1.pattern == Pattern.sinogram
+
+
+def test_sectionizer_platform_change_has_no_effect(mocker: MockerFixture):
+    p = Pipeline(
+        loader=make_test_loader(mocker),
+        methods=[
+            make_test_method(mocker, gpu=True),
+            make_test_method(mocker, gpu=False),
+        ],
+    )
+
+    s = sectionize(p)
+    assert len(s) == 1
+    s0 = s[0]
+    assert len(s0) == 2
+    assert s0[0].is_gpu is True
+    assert s0[1].is_gpu is False
+
+
+@pytest.mark.parametrize(
+    "loader_pattern, pattern1, pattern2, expected",
+    [
+        (Pattern.all, Pattern.projection, Pattern.all, Pattern.projection),
+        (Pattern.all, Pattern.all, Pattern.projection, Pattern.projection),
+        (Pattern.all, Pattern.sinogram, Pattern.all, Pattern.sinogram),
+        (Pattern.all, Pattern.all, Pattern.sinogram, Pattern.sinogram),
+        (Pattern.all, Pattern.all, Pattern.all, Pattern.projection),
+    ],
+    ids=[
+        "proj-all-proj",
+        "all-proj-proj",
+        "sino-all-sino",
+        "all-sino-sino",
+        "all-all-all",
+    ],
+)
+def test_determine_platform_sections_pattern_all_combine(
+    mocker: MockerFixture,
+    loader_pattern: Pattern,
+    pattern1: Pattern,
+    pattern2: Pattern,
+    expected: Pattern,
+):
+    loader = make_test_loader(mocker, pattern=loader_pattern)
+    p = Pipeline(
+        loader=loader,
+        methods=[
+            make_test_method(mocker, pattern=pattern1),
+            make_test_method(mocker, pattern=pattern2),
+        ],
+    )
+
+    s = sectionize(p)
+    assert len(s) == 1
+    s0 = s[0]
+    assert len(s0) == 2
+    assert s0.pattern == expected
+    assert s0[0].pattern == expected
+    assert s0[1].pattern == expected
+    assert loader.pattern == expected
+
+
+@pytest.mark.parametrize(
+    "pattern",
+    [Pattern.projection, Pattern.sinogram],
+    ids=["proj", "sino"],
+)
+def test_sectionizer_inherits_pattern_from_before_if_all(
+    mocker: MockerFixture, pattern: Pattern
+):
+    loader = make_test_loader(mocker, pattern=Pattern.all)
+    p = Pipeline(
+        loader=loader,
+        methods=[
+            make_test_method(mocker, pattern=pattern),
+            make_test_method(mocker, pattern=Pattern.all),
+        ],
+    )
+
+    s = sectionize(p)
+    assert len(s) == 1
+    assert s[0].pattern == pattern
+    assert s[0][1].pattern == pattern
+
+
+@pytest.mark.parametrize("loader_pattern", [Pattern.projection, Pattern.sinogram])
+def test_sectionizer_inherits_loader_pattern(
+    mocker: MockerFixture, loader_pattern: Pattern
+):
+    p = Pipeline(
+        loader=make_test_loader(mocker, pattern=loader_pattern),
+        methods=[make_test_method(mocker, pattern=Pattern.all, gpu=True)],
+    )
+
+    s = sectionize(p)
+    assert len(s) == 1
+    assert s[0].pattern == loader_pattern
+
+
+def test_sectionizer_sets_islast_single(mocker: MockerFixture):
+    p = Pipeline(
+        loader=make_test_loader(mocker, pattern=Pattern.projection),
+        methods=[make_test_method(mocker, pattern=Pattern.projection)],
+    )
+    s = sectionize(p)
+
+    assert s[-1].is_last is True
+
+
+def test_sectionizer_sets_islast_multiple(mocker: MockerFixture):
+    p = Pipeline(
+        loader=make_test_loader(mocker, pattern=Pattern.projection),
+        methods=[
+            make_test_method(mocker, pattern=Pattern.projection),
+            make_test_method(mocker, pattern=Pattern.sinogram),
+        ],
+    )
+    s = sectionize(p)
+
+    assert s[0].is_last is False
+    assert s[1].is_last is True
+
+
+@pytest.mark.parametrize("positions", [(0, 1), (2, 3), (0, 4), (1, 2)])
+def test_sectionizer_output_ref_triggers_new_section(
+    mocker: MockerFixture, positions: Tuple[int, int]
+):
+    referenced_method = make_test_method(mocker, method_name="referenced_method")
+    referring_method = make_test_method(
+        mocker,
+        method_name="referring_method",
+        center=OutputRef(referenced_method, "testout"),
+    )
+    methods = [make_test_method(mocker, method_name=f"m{i}") for i in range(5)]
+    methods.insert(positions[0], referenced_method)
+    methods.insert(positions[1], referring_method)
+    p = Pipeline(
+        loader=make_test_loader(mocker),
+        methods=methods,
+    )
+
+    s = sectionize(p)
+    assert len(s) == 2
+    # both must be in separate sections
+    assert "referenced_method" in [m.method_name for m in s[0]]
+    assert "referring_method" in [m.method_name for m in s[1]]
+
+
+def test_sectionizer_output_ref_after_regular_section_break_does_nothing(
+    mocker: MockerFixture,
+):
+    referenced_method = make_test_method(
+        mocker, method_name="referenced_method", patterm=Pattern.projection
+    )
+    referring_method = make_test_method(
+        mocker,
+        method_name="referring_method",
+        pattern=Pattern.sinogram,
+        center=OutputRef(referenced_method, "testout"),
+    )
+    proj = [
+        make_test_method(mocker, method_name=f"p{i}", pattern=Pattern.projection)
+        for i in range(5)
+    ]
+    proj.insert(1, referenced_method)
+    sino = [
+        make_test_method(mocker, method_name=f"s{i}", pattern=Pattern.sinogram)
+        for i in range(5)
+    ]
+    sino.insert(1, referring_method)
+    methods = proj + sino
+    p = Pipeline(
+        loader=make_test_loader(mocker),
+        methods=methods,
+    )
+
+    s = sectionize(p)
+    assert len(s) == 2
+    # both must be in separate sections
+    assert "referenced_method" in [m.method_name for m in s[0]]
+    assert "referring_method" in [m.method_name for m in s[1]]
+    assert len(s[0]) == 6
+    assert len(s[1]) == 6
+
+@pytest.mark.parametrize("patterns", [
+    (Pattern.sinogram, Pattern.projection),
+    (Pattern.projection, Pattern.sinogram)
+], ids=["sino-proj", "proj-sino"])
+def test_sectionizer_inserts_empty_section_if_loader_pattern_mismatches(
+    mocker: MockerFixture, patterns: Tuple[Pattern, Pattern]
+):
+    loader = make_test_loader(mocker, pattern=patterns[0])
+    p = Pipeline(
+        loader=loader,
+        methods=[make_test_method(mocker, pattern=patterns[1])],
+    )
+
+    s = sectionize(p)
+    assert len(s) == 2
+    assert loader.pattern == patterns[0]
+    assert len(s[0]) == 0  # emtpy section, so that reslicing happens after loader
+    assert s[0].pattern == patterns[0]
+    assert len(s[1]) == 1
+    assert s[1].pattern == patterns[1]
diff --git a/tests/runner/test_task_runner.py b/tests/runner/test_task_runner.py
index a97c24d1d..7db0e25d4 100644
--- a/tests/runner/test_task_runner.py
+++ b/tests/runner/test_task_runner.py
@@ -1,18 +1,27 @@
+from os import PathLike
 from typing import List
 from unittest.mock import ANY, call
 import pytest
 import numpy as np
 from pytest_mock import MockerFixture
-from httomo.runner.dataset import DataSet
+import httomo
+from httomo.data.dataset_store import DataSetStoreWriter
+from httomo.runner.auxiliary_data import AuxiliaryData
+from httomo.runner.dataset import DataSetBlock
 from httomo.runner.methods_repository_interface import GpuMemoryRequirement
+from httomo.runner.monitoring_interface import MonitoringInterface
+from httomo.runner.output_ref import OutputRef
 from httomo.runner.pipeline import Pipeline
-from httomo.runner.platform_section import sectionize
+from httomo.runner.section import Section, sectionize
 from httomo.runner.task_runner import TaskRunner
 from httomo.utils import Pattern, xp, gpu_enabled
-from .testing_utils import make_test_loader, make_test_method
+from httomo.runner.method_wrapper import GpuTimeInfo, MethodWrapper
+from ..testing_utils import make_test_loader, make_test_method
 
 
-def test_check_params_for_sweep_raises_exception(mocker: MockerFixture):
+def test_check_params_for_sweep_raises_exception(
+    mocker: MockerFixture, tmp_path: PathLike
+):
     p = Pipeline(
         loader=make_test_loader(mocker),
         methods=[
@@ -24,57 +33,57 @@ def test_check_params_for_sweep_raises_exception(mocker: MockerFixture):
                 ),
             )
         ],
-        save_results_set=[False],
     )
-    t = TaskRunner(p)
+    t = TaskRunner(p, reslice_dir=tmp_path)
     with pytest.raises(ValueError) as e:
         t._check_params_for_sweep()
 
 
-def test_can_load_datasets(mocker: MockerFixture):
+def test_can_load_datasets(mocker: MockerFixture, tmp_path: PathLike):
     loader = make_test_loader(mocker)
-    dataset = DataSet(
-        data=np.ones((10, 10, 10)),
-        darks=np.ones((10, 10)),
-        flats=np.ones((10, 10)),
-        angles=np.ones((10,)),
-    )
-    loader.load.return_value = dataset
-    p = Pipeline(
-        loader=loader, methods=[make_test_method(mocker)], save_results_set=[False]
-    )
-    t = TaskRunner(p)
+    mksrc = mocker.patch.object(loader, "make_data_source")
+    p = Pipeline(loader=loader, methods=[make_test_method(mocker)])
+    t = TaskRunner(p, reslice_dir=tmp_path)
     t._prepare()
 
-    loader.load.assert_called()
-    assert t.dataset is not None
-    np.testing.assert_array_equal(t.dataset.data, 1)
+    mksrc.assert_called()
+    assert t.source is not None
 
 
-@pytest.mark.skipif(
-    not gpu_enabled or xp.cuda.runtime.getDeviceCount() == 0,
-    reason="skipped as cupy is not available",
-)
-def test_can_determine_max_slices_no_gpu_estimator(mocker: MockerFixture):
-    loader = make_test_loader(mocker)
-    dataset = DataSet(
-        data=np.ones((10, 10, 10)),
-        darks=np.ones((10, 10)),
-        flats=np.ones((10, 10)),
-        angles=np.ones((10,)),
-    )
-    loader.load.return_value = dataset
-    method = make_test_method(mocker, gpu=True)
-    method.memory_gpu = []
-    p = Pipeline(loader=loader, methods=[method], save_results_set=[False])
-    t = TaskRunner(p)
+def test_can_determine_max_slices_no_gpu_estimator(
+    mocker: MockerFixture, tmp_path: PathLike, dummy_block: DataSetBlock
+):
+    loader = make_test_loader(mocker, dummy_block)
+    method = make_test_method(mocker, gpu=True, memory_gpu=[])
+    p = Pipeline(loader=loader, methods=[method])
+    t = TaskRunner(p, reslice_dir=tmp_path)
     t._prepare()
-    s = sectionize(p, False)
-    assert s[0].gpu is True
+    s = sectionize(p)
 
     t.determine_max_slices(s[0], 0)
 
-    assert s[0].max_slices == dataset.data.shape[0]
+    assert s[0].max_slices == dummy_block.chunk_shape[0]
+
+
+@pytest.mark.parametrize("slices", [10, 500])
+def test_can_determine_max_slices_empty_section(
+    mocker: MockerFixture, tmp_path: PathLike, slices: int
+):
+    data = np.ones((slices, 10, 10), dtype=np.float32)
+    aux = AuxiliaryData(angles=np.ones(slices, dtype=np.float32))
+    block = DataSetBlock(data, aux)
+
+    loader = make_test_loader(mocker, block)
+    method = make_test_method(mocker, gpu=True, memory_gpu=[])
+    p = Pipeline(loader=loader, methods=[method])
+    t = TaskRunner(p, reslice_dir=tmp_path)
+    t._prepare()
+    s = sectionize(p)
+    s.insert(0, Section(pattern=Pattern.sinogram, max_slices=0, methods=[]))
+
+    t.determine_max_slices(s[0], 0)
+
+    assert s[0].max_slices == min(slices, httomo.globals.MAX_CPU_SLICES)
 
 
 @pytest.mark.skipif(
@@ -89,124 +98,253 @@ def test_can_determine_max_slices_no_gpu_estimator(mocker: MockerFixture):
     ],
 )
 def test_can_determine_max_slices_with_gpu_estimator(
-    mocker: MockerFixture, dummy_dataset: DataSet, max_slices_methods: List[int]
+    mocker: MockerFixture,
+    max_slices_methods: List[int],
+    tmp_path: PathLike,
+    dummy_block: DataSetBlock,
 ):
-    loader = make_test_loader(mocker)
-    loader.load.return_value = dummy_dataset
-    methods = []
-    save_results_list = []
+    loader = make_test_loader(mocker, dummy_block)
+    methods: List[MethodWrapper] = []
+    calc_dims_mocks = []
+    calc_max_slices_mocks = []
     for i, max_slices in enumerate(max_slices_methods):
         method = make_test_method(mocker, gpu=True)
-        method.memory_gpu = [
-            GpuMemoryRequirement(dataset="tomo", multiplier=2.0, method="direct")
-        ]
-        method.calculate_output_dims.return_value = (10, 10)
-        method.calculate_max_slices.return_value = (max_slices, 1000000)
+        mocker.patch.object(
+            method,
+            "memory_gpu",
+            [GpuMemoryRequirement(dataset="tomo", multiplier=2.0, method="direct")],
+        )
+        calc_dims_mocks.append(
+            mocker.patch.object(method, "calculate_output_dims", return_value=(10, 10))
+        )
+        calc_max_slices_mocks.append(
+            mocker.patch.object(
+                method, "calculate_max_slices", return_value=(max_slices, 1000000)
+            )
+        )
         methods.append(method)
-        save_results_list.append(False)
-    p = Pipeline(loader=loader, methods=methods, save_results_set=save_results_list)
-    t = TaskRunner(p)
+    p = Pipeline(loader=loader, methods=methods)
+    t = TaskRunner(p, reslice_dir=tmp_path)
     t._prepare()
-    s = sectionize(p, False)
-    assert s[0].gpu is True
+    s = sectionize(
+        p,
+    )
     assert len(s[0]) == len(max_slices_methods)
-    shape = list(dummy_dataset.data.shape)
-    shape.pop(0)
+    shape = (dummy_block.shape[1], dummy_block.shape[2])
 
     t.determine_max_slices(s[0], 0)
 
     assert s[0].max_slices == min(max_slices_methods)
-    methods[0].calculate_output_dims.assert_called_with(tuple(shape))
-    methods[0].calculate_max_slices.assert_called_with(dummy_dataset, tuple(shape), ANY)
-    for i in range(1, len(max_slices_methods)):
-        methods[i].calculate_output_dims.assert_called_with((10, 10))
-        methods[i].calculate_max_slices.assert_called_with(dummy_dataset, (10, 10), ANY)
+    for i in range(len(max_slices_methods)):
+        calc_dims_mocks[i].assert_called_with(shape)
+        calc_max_slices_mocks[i].assert_called_with(
+            dummy_block.data.dtype,
+            shape,
+            ANY,
+        )
+
+
+@pytest.mark.parametrize("limit", [0, 10000])
+def test_can_determine_max_slices_with_gpu_estimator_and_cpu_limit(
+    mocker: MockerFixture,
+    limit: int,
+    tmp_path: PathLike,
+    dummy_block: DataSetBlock,
+):
+    mocker.patch("httomo.runner.task_runner.get_available_gpu_memory", return_value=1e7)
+    loader = make_test_loader(mocker, dummy_block)
+    methods: List[MethodWrapper] = []
+    calc_dims_mocks = []
+    calc_max_slices_mocks = []
+    for i in range(3):
+        method = make_test_method(mocker, gpu=True)
+        mocker.patch.object(
+            method,
+            "memory_gpu",
+            [GpuMemoryRequirement(dataset="tomo", multiplier=2.0, method="direct")],
+        )
+        calc_dims_mocks.append(
+            mocker.patch.object(method, "calculate_output_dims", return_value=(10, 10))
+        )
+        calc_max_slices_mocks.append(
+            mocker.patch.object(
+                method, "calculate_max_slices", return_value=(10, limit if limit != 0 else 1e7)
+            )
+        )
+        methods.append(method)
+    p = Pipeline(loader=loader, methods=methods)
+    
+    t = TaskRunner(p, reslice_dir=tmp_path, memory_limit_bytes=limit)
+    t._prepare()
+    s = sectionize(p)
+    shape = (dummy_block.shape[1], dummy_block.shape[2])
+
+    t.determine_max_slices(s[0], 0)
+
+    for i in range(3):
+        calc_dims_mocks[i].assert_called_with(shape)
+        calc_max_slices_mocks[i].assert_called_with(
+            dummy_block.data.dtype,
+            shape,
+            limit if limit != 0 else 1e7,
+        )
 
 
 def test_can_determine_max_slices_with_cpu(
-    mocker: MockerFixture, dummy_dataset: DataSet
+    mocker: MockerFixture, tmp_path: PathLike, dummy_block: DataSetBlock
 ):
-    loader = make_test_loader(mocker)
-    loader.load.return_value = dummy_dataset
+    loader = make_test_loader(mocker, dummy_block)
     methods = []
-    for i in range(3):
+    for _ in range(3):
         method = make_test_method(mocker, gpu=False)
         methods.append(method)
-    p = Pipeline(loader=loader, methods=methods, save_results_set=[False, False, False])
-    t = TaskRunner(p)
+    p = Pipeline(loader=loader, methods=methods)
+    t = TaskRunner(p, reslice_dir=tmp_path)
     t._prepare()
-    s = sectionize(p, False)
-    assert s[0].gpu is False
+    s = sectionize(p)
 
     t.determine_max_slices(s[0], 0)
-    assert s[0].max_slices == dummy_dataset.data.shape[0]
+    assert s[0].max_slices == dummy_block.chunk_shape[0]
 
 
-def test_calls_update_side_inputs_after_call(
-    mocker: MockerFixture, dummy_dataset: DataSet
+def test_can_determine_max_slices_with_cpu_large(
+    mocker: MockerFixture, tmp_path: PathLike
 ):
-    loader = make_test_loader(mocker)
-    loader.load.return_value = dummy_dataset
+    mocker.patch.object(httomo.globals, "MAX_CPU_SLICES", 16)
+    data = np.ones((500, 10, 10), dtype=np.float32)
+    aux = AuxiliaryData(angles=np.ones(500, dtype=np.float32))
+    block = DataSetBlock(data, aux)
+    loader = make_test_loader(mocker, block)
+    methods = []
+    for _ in range(3):
+        method = make_test_method(mocker, gpu=False)
+        methods.append(method)
+    p = Pipeline(loader=loader, methods=methods)
+    t = TaskRunner(p, reslice_dir=tmp_path)
+    t._prepare()
+    s = sectionize(p)
 
-    side_outputs = {"answer": 42, "other": "xxx"}
+    t.determine_max_slices(s[0], 0)
+    assert s[0].max_slices == 16
 
-    method1 = make_test_method(mocker)
-    method1.execute.return_value = dummy_dataset
-    method1.get_side_output.return_value = side_outputs
-    method2 = make_test_method(mocker)
 
-    p = Pipeline(
-        loader=loader, methods=[method1, method2], save_results_set=[False, False]
+def test_append_side_outputs(mocker: MockerFixture, tmp_path: PathLike):
+    p = Pipeline(make_test_loader(mocker), methods=[])
+    t = TaskRunner(p, reslice_dir=tmp_path)
+    t.append_side_outputs({"answer": 42.0, "other": "xxx"})
+    assert t.side_outputs == {"answer": 42.0, "other": "xxx"}
+    
+
+def test_calls_append_side_outputs_after_last_block(
+    mocker: MockerFixture, tmp_path: PathLike,
+):
+    GLOBAL_SHAPE = (500, 10, 10)
+    CHUNK_SHAPE = GLOBAL_SHAPE
+    data = np.ones(GLOBAL_SHAPE, dtype=np.float32)
+    aux = AuxiliaryData(angles=np.ones(GLOBAL_SHAPE[0], dtype=np.float32))
+    block1 = DataSetBlock(
+        data=data[:GLOBAL_SHAPE[0] // 2, :, :],
+        aux_data=aux,
+        block_start=0,
+        chunk_start=0,
+        chunk_shape=CHUNK_SHAPE,
+        global_shape=GLOBAL_SHAPE
     )
-    t = TaskRunner(p)
-    spy = mocker.patch.object(t, "update_side_inputs")
+    block2 = DataSetBlock(
+        data=data[GLOBAL_SHAPE[0] // 2:, :, :],
+        aux_data=aux,
+        block_start=CHUNK_SHAPE[0] // 2,
+        chunk_start=0,
+        chunk_shape=CHUNK_SHAPE,
+        global_shape=GLOBAL_SHAPE
+    )
+
+    method = make_test_method(mocker)
+    mocker.patch.object(method, "execute", side_effect=[block1, block2])
+    side_outputs = {"answer": 42, "other": "xxx"}
+    getmock = mocker.patch.object(method, "get_side_output", return_value=side_outputs)
+
+    loader = make_test_loader(mocker)
+    p = Pipeline(loader=loader, methods=[method])
+    t = TaskRunner(p, reslice_dir=tmp_path)
+    spy = mocker.patch.object(t, "append_side_outputs")
     t._prepare()
-    t._execute_method(method1, 2, dummy_dataset)
+    t._execute_method(method, block1) # the first block shouldn't trigger a side output append call
+    assert spy.call_count == 0
 
-    spy.assert_called_with(side_outputs)
-    t.side_outputs == side_outputs
+    t._execute_method(method, block2)  # the last block should trigger side output append call
+    getmock.assert_called_once()
+    spy.assert_called_once_with(side_outputs)
 
 
-def test_update_side_inputs_updates_downstream_methods(mocker: MockerFixture):
+def test_update_side_inputs_updates_downstream_methods(
+    mocker: MockerFixture, tmp_path: PathLike
+):
     loader = make_test_loader(mocker)
     side_outputs = {"answer": 42, "other": "xxx"}
     method1 = make_test_method(mocker, method_name="m1")
     method2 = make_test_method(mocker, method_name="m2")
-    method2.parameters = ["answer"]
+    mocker.patch.object(method2, "parameters", ["answer"])
+    setitem2 = mocker.patch.object(method2, "__setitem__")
     method3 = make_test_method(mocker, method_name="m3")
-    method3.parameters = ["answer", "other", "whatever"]
+    mocker.patch.object(method3, "parameters", ["answer", "other", "whatever"])
+    setitem3 = mocker.patch.object(method3, "__setitem__")
 
-    p = Pipeline(
-        loader=loader,
-        methods=[method1, method2, method3],
-        save_results_set=[False, False, False],
-    )
-    t = TaskRunner(p)
-    t.method_index = 2  # pretend we're after executing method1
-    t.update_side_inputs(side_outputs)
+    p = Pipeline(loader=loader, methods=[method1, method2, method3])
+    t = TaskRunner(p, reslice_dir=tmp_path)
+    t.side_outputs = side_outputs
+    t.set_side_inputs(method2)
+    t.set_side_inputs(method3)
 
-    method2.__setitem__.assert_called_with("answer", 42)
+    setitem2.assert_called_with("answer", 42)
     method3_calls = [call("answer", 42), call("other", "xxx")]
-    method3.__setitem__.assert_has_calls(method3_calls)
-
+    setitem3.assert_has_calls(method3_calls)
 
-def test_execute_section_calls_blockwise_execute(
-    mocker: MockerFixture, dummy_dataset: DataSet
+def test_execute_method_updates_monitor(
+    mocker: MockerFixture, tmp_path: PathLike, dummy_block: DataSetBlock
 ):
-    original_value = dummy_dataset.data[0, 0, 0]  # it has all the same number
     loader = make_test_loader(mocker)
-    loader.load.return_value = dummy_dataset
+    method1 = make_test_method(mocker)
+    mocker.patch.object(method1, "gpu_time", GpuTimeInfo(kernel=42.0, device2host=1.0, host2device=2.0))
+    mon = mocker.create_autospec(MonitoringInterface, instance=True)
+    p = Pipeline(loader=loader, methods=[method1])
+    t = TaskRunner(p, reslice_dir=tmp_path, monitor=mon)
+    t._prepare()
+    mocker.patch.object(method1, "execute", return_value=dummy_block)
+    t._execute_method(method1, dummy_block)
+
+    mon.report_method_block.assert_called_once_with(
+        method1.method_name,
+        method1.module_path,
+        method1.task_id,
+        0,
+        dummy_block.shape,
+        dummy_block.chunk_index,
+        dummy_block.global_index,
+        ANY,
+        42.0,
+        2.0,
+        1.0
+    )
+
+
+def test_execute_section_calls_blockwise_execute_and_monitors(
+    mocker: MockerFixture, dummy_block: DataSetBlock, tmp_path: PathLike
+):
+    original_value = dummy_block.data[0, 0, 0]  # it has all the same number
+    loader = make_test_loader(mocker, dummy_block)
     method = make_test_method(mocker, method_name="m1")
-    p = Pipeline(loader=loader, methods=[method], save_results_set=[False])
-    s = sectionize(p, False)
-    t = TaskRunner(p)
+    p = Pipeline(loader=loader, methods=[method])
+    s = sectionize(p)
+    mon = mocker.create_autospec(MonitoringInterface, instance=True)
+    t = TaskRunner(p, reslice_dir=tmp_path, monitor=mon)
     t._prepare()
     # make that do nothing
     mocker.patch.object(t, "determine_max_slices")
-    s[0].max_slices = dummy_dataset.data.shape[0] / 2  # we'll have 2 blocks
+    s[0].max_slices = dummy_block.chunk_shape[0] // 2  # we'll have 2 blocks
 
     # make this function return the block, with data multiplied by 2
-    def mul_block_by_two(section, block: DataSet):
+    def mul_block_by_two(section, block: DataSetBlock):
         block.data *= 2
         return block
 
@@ -214,163 +352,130 @@ def mul_block_by_two(section, block: DataSet):
         t, "_execute_section_block", side_effect=mul_block_by_two
     )
 
+    s[0].is_last = False  # should setup a DataSetStoreWriter as sink
     t._execute_section(s[0], 1)
+    assert isinstance(t.sink, DataSetStoreWriter)
+    reader = t.sink.make_reader()
+    data = reader.read_block(0, dummy_block.shape[0])
 
-    np.testing.assert_allclose(t.dataset.data, original_value * 2)
+    np.testing.assert_allclose(data.data, original_value * 2)
     calls = [call(ANY, ANY), call(ANY, ANY)]
     block_mock.assert_has_calls(calls)  # make sure we got called twice
+    assert mon.report_source_block.call_count == 2
+    assert mon.report_sink_block.call_count == 2
 
 
-def test_execute_section_for_block(mocker: MockerFixture, dummy_dataset: DataSet):
-    loader = make_test_loader(mocker)
-    loader.load.return_value = dummy_dataset
+def test_execute_section_for_block(
+    mocker: MockerFixture, tmp_path: PathLike, dummy_block: DataSetBlock
+):
+    loader = make_test_loader(mocker, dummy_block)
     method1 = make_test_method(mocker, method_name="m1")
     method2 = make_test_method(mocker, method_name="m2")
-    p = Pipeline(
-        loader=loader, methods=[method1, method2], save_results_set=[False, False]
-    )
-    s = sectionize(p, False)
-    t = TaskRunner(p)
+    p = Pipeline(loader=loader, methods=[method1, method2])
+    s = sectionize(p)
+    t = TaskRunner(p, reslice_dir=tmp_path)
     t._prepare()
-    exec_method = mocker.patch.object(t, "_execute_method", return_value=dummy_dataset)
-    t._execute_section_block(s[0], dummy_dataset)
+    exec_method = mocker.patch.object(t, "_execute_method", return_value=dummy_block)
+    t._execute_section_block(s[0], dummy_block)
 
-    calls = [call(method1, ANY, ANY), call(method2, ANY, ANY)]
+    calls = [call(method1, ANY), call(method2, ANY)]
     exec_method.assert_has_calls(calls)
 
 
-@pytest.mark.parametrize("filebased", [False, True])
-def test_does_reslice_when_needed(
-    mocker: MockerFixture, dummy_dataset: DataSet, filebased: bool
+def test_does_reslice_when_needed_and_reports_time(
+    mocker: MockerFixture, dummy_block: DataSetBlock, tmp_path: PathLike
 ):
-    loader = make_test_loader(mocker)
-    loader.load.return_value = dummy_dataset
+    loader = make_test_loader(mocker, dummy_block)
     method1 = make_test_method(mocker, method_name="m1", pattern=Pattern.projection)
+    mocker.patch.object(method1, "execute", return_value=dummy_block)
     method2 = make_test_method(mocker, method_name="m2", pattern=Pattern.sinogram)
-    p = Pipeline(
-        loader=loader, methods=[method1, method2], save_results_set=[False, False]
+    # create a block in the other slicing dim
+    block2 = DataSetBlock(
+        data=dummy_block.data,
+        aux_data=dummy_block.aux_data,
+        slicing_dim=1,
     )
-    reslice_dir = "./current" if filebased else None
-    t = TaskRunner(p, reslice_dir=reslice_dir)
-
-    exec_section = mocker.patch.object(t, "_execute_section")
-    reslice = mocker.patch("httomo.runner.task_runner.reslice")
-    reslice_filebased = mocker.patch("httomo.runner.task_runner.reslice_filebased")
+    mocker.patch.object(method2, "execute", return_value=block2)
+    p = Pipeline(loader=loader, methods=[method1, method2])
+    mon = mocker.create_autospec(MonitoringInterface, instance=True)
+    t = TaskRunner(p, reslice_dir=tmp_path, monitor=mon)
 
     t.execute()
 
-    exec_section.assert_has_calls([call(ANY, 0), call(ANY, 1)])
-    assert t.reslice_count == 1
-    if filebased:
-        reslice_filebased.assert_called_once_with(
-            ANY, 1, 2, dummy_dataset.angles, 10, 10, t.comm, reslice_dir
-        )
-    else:
-        reslice.assert_called_once_with(ANY, 1, 2, t.comm)
+    assert loader.pattern == Pattern.projection
+    assert t.source is not None
+    assert t.sink is not None
+    assert t.source.slicing_dim == 1
+    assert t.sink.slicing_dim == 1
+    
+    mon.report_total_time.assert_called_once()
 
 
-def test_warns_after_multiple_reslices(mocker: MockerFixture, dummy_dataset: DataSet):
-    loader = make_test_loader(mocker)
-    loader.load.return_value = dummy_dataset
+def test_warns_with_multiple_reslices(
+    mocker: MockerFixture,
+    dummy_block: DataSetBlock,
+    tmp_path: PathLike,
+):
+    loader = make_test_loader(mocker, dummy_block, pattern=Pattern.projection)
     method1 = make_test_method(mocker, method_name="m1", pattern=Pattern.projection)
     method2 = make_test_method(mocker, method_name="m2", pattern=Pattern.sinogram)
     method3 = make_test_method(mocker, method_name="m3", pattern=Pattern.projection)
-    p = Pipeline(
-        loader=loader,
-        methods=[method1, method2, method3],
-        save_results_set=[False, False, False],
-    )
-    t = TaskRunner(p)
+    method4 = make_test_method(mocker, method_name="m4", pattern=Pattern.sinogram)
+    method5 = make_test_method(mocker, method_name="m5", pattern=Pattern.projection)
+    p = Pipeline(loader=loader, methods=[method1, method2, method3, method4, method5])
+    t = TaskRunner(p, reslice_dir=tmp_path)
 
-    exec_section = mocker.patch.object(t, "_execute_section")
-    reslice = mocker.patch("httomo.runner.task_runner.reslice")
+    spy = mocker.patch("httomo.runner.task_runner.log_once")
 
-    t.execute()
-
-    exec_section.assert_has_calls([call(ANY, 0), call(ANY, 1), call(ANY, 2)])
-    reslice.assert_called()
-    assert t.reslice_count == 2
-    assert t.has_reslice_warn_printed is True
-
-
-def test_no_reslice_if_not_needed(mocker: MockerFixture, dummy_dataset: DataSet):
-    loader = make_test_loader(mocker)
-    loader.load.return_value = dummy_dataset
-    method1 = make_test_method(mocker, method_name="m1")
-    method2 = make_test_method(mocker, method_name="m2", gpu=True)
-    method3 = make_test_method(mocker, method_name="m3")
-    p = Pipeline(
-        loader=loader,
-        methods=[method1, method2, method3],
-        save_results_set=[False, False, False],
-    )
-    t = TaskRunner(p)
+    t._sectionize()
 
-    exec_section = mocker.patch.object(t, "_execute_section")
-    reslice = mocker.patch("httomo.runner.task_runner.reslice")
-
-    t.execute()
+    spy.assert_called()
+    args, _ = spy.call_args
+    assert "Data saving or/and reslicing operation will be performed 4 times" in args[0]
 
-    exec_section.assert_has_calls([call(ANY, 0), call(ANY, 1), call(ANY, 2)])
-    assert t.reslice_count == 0
-    assert t.has_reslice_warn_printed is False
-    reslice.assert_not_called()
 
-
-@pytest.mark.parametrize(
-    "method_name,dim,save",
-    [
-        ("m1", 3, True),
-        ("m1", 2, False),
-        ("save_to_images", 3, False),
-        ("find_center_vo", 3, False),
-    ],
-)
-def test_saves_intermediate_results(
+def test_warns_with_multiple_stores_from_side_outputs(
     mocker: MockerFixture,
-    dummy_dataset: DataSet,
-    method_name: str,
-    dim: int,
-    save: bool,
+    dummy_block: DataSetBlock,
+    tmp_path: PathLike,
 ):
-    if dim != 3:
-        dummy_dataset.data = np.squeeze(dummy_dataset.data[0, :, :])
-    loader = make_test_loader(mocker)
-    loader.load.return_value = dummy_dataset
-    loader.detector_x = 15
-    loader.detector_y = 42
-    method1 = make_test_method(
+    # Mock pipeline contains all projection methods, so no reslices occur. However, each method
+    # requires side output from previous method, which causes data to be written to store after
+    # each method
+    loader = make_test_loader(mocker, dummy_block, pattern=Pattern.projection)
+    method1 = make_test_method(mocker, method_name="m1", pattern=Pattern.projection)
+    method2 = make_test_method(
         mocker,
-        method_name=method_name,
-        module_path="path1",
+        method_name="m2",
+        pattern=Pattern.projection,
+        param1=OutputRef(method1, "method1_out"),
     )
-    method1.recon_algorithm = "testalgo"
-    method2 = make_test_method(mocker, method_name="m2")
-    p = Pipeline(
-        loader=loader, methods=[method1, method2], save_results_set=[True, False]
+    method3 = make_test_method(
+        mocker,
+        method_name="m3",
+        pattern=Pattern.projection,
+        param2=OutputRef(method2, "method2_out"),
+    )
+    method4 = make_test_method(
+        mocker,
+        method_name="m4",
+        pattern=Pattern.projection,
+        param3=OutputRef(method3, "method3_out"),
+    )
+    method5 = make_test_method(
+        mocker,
+        method_name="m5",
+        pattern=Pattern.projection,
+        param4=OutputRef(method4, "method4_out"),
     )
-    t = TaskRunner(p)
 
-    exec_section = mocker.patch.object(t, "_execute_section")
-    intermediate_save = mocker.patch("httomo.runner.task_runner.intermediate_dataset")
+    p = Pipeline(loader=loader, methods=[method1, method2, method3, method4, method5])
+    t = TaskRunner(p, reslice_dir=tmp_path)
 
-    t.execute()
+    spy = mocker.patch("httomo.runner.task_runner.log_once")
 
-    exec_section.assert_has_calls([call(ANY, 0), call(ANY, 1)])
-    if save:
-        intermediate_save.assert_called_once_with(
-            ANY,  # data
-            ANY,  # run_out_dir
-            ANY,  # angles
-            15,  # detector_x
-            42,  # detector_y
-            ANY,  # comm
-            2,  # method index: loader + method1
-            method1.package_name,  # package_name
-            method1.method_name,  # method_name
-            "tomo",  # dataset name
-            1,  # slicing dim
-            "testalgo",  # algo name
-        )
-    else:
-        intermediate_save.assert_not_called()
+    t._sectionize()
+
+    spy.assert_called()
+    args, _ = spy.call_args
+    assert "Data saving or/and reslicing operation will be performed 4 times" in args[0]
diff --git a/tests/runner/test_wrappers.py b/tests/runner/test_wrappers.py
deleted file mode 100644
index a4202933a..000000000
--- a/tests/runner/test_wrappers.py
+++ /dev/null
@@ -1,1069 +0,0 @@
-from typing import List, Optional, Union
-from unittest.mock import ANY
-from mpi4py import MPI
-import numpy as np
-import httomo
-from httomo.runner.dataset import DataSet
-from httomo.runner.backend_wrapper import RotationWrapper, make_backend_wrapper
-from httomo.runner.methods_repository_interface import GpuMemoryRequirement
-from httomo.runner.output_ref import OutputRef
-from httomo.utils import Pattern, xp, gpu_enabled
-from pytest_mock import MockerFixture
-import pytest
-
-from .testing_utils import make_mock_repo, make_test_method
-
-
-def test_basewrapper_get_name_and_paths(mocker: MockerFixture):
-    class FakeModule:
-        def fake_method(data):
-            return data
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker),
-        "testmodule.path",
-        "fake_method",
-        MPI.COMM_WORLD,
-    )
-
-    assert wrp.method_name == "fake_method"
-    assert wrp.module_path == "testmodule.path"
-    assert wrp.package_name == "testmodule"
-
-
-def test_basewrapper_execute_transfers_to_gpu(
-    dummy_dataset: DataSet, mocker: MockerFixture
-):
-    class FakeModule:
-        def fake_method(data):
-            return data
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker, implementation="gpu_cupy"),
-        "module_path",
-        "fake_method",
-        MPI.COMM_WORLD,
-    )
-    dataset = wrp.execute(dummy_dataset)
-
-    assert dataset.is_gpu == gpu_enabled
-
-
-def test_basewrapper_execute_calls_pre_post_process(
-    mocker: MockerFixture, dummy_dataset: DataSet
-):
-    class FakeModule:
-        def fake_method(data):
-            return data
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker, implementation="gpu_cupy"),
-        "module_path",
-        "fake_method",
-        MPI.COMM_WORLD,
-    )
-    prep = mocker.patch.object(wrp, "_preprocess_data", return_value=dummy_dataset)
-    post = mocker.patch.object(wrp, "_postprocess_data", return_value=dummy_dataset)
-    trans = mocker.patch.object(wrp, "_transfer_data", return_value=dummy_dataset)
-
-    wrp.execute(dummy_dataset)
-
-    prep.assert_called_once_with(dummy_dataset)
-    post.assert_called_once_with(dummy_dataset)
-    trans.assert_called_once_with(dummy_dataset)
-
-
-def test_wrapper_fails_with_wrong_returntype(
-    mocker: MockerFixture, dummy_dataset: DataSet
-):
-    class FakeModule:
-        def fake_method(data):
-            # returning None should not be allowed for generic wrapper,
-            # it must be a cpu or gpu array (data)
-            return None
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker), "mocked_module_path", "fake_method", MPI.COMM_WORLD
-    )
-    with pytest.raises(ValueError) as e:
-        wrp.execute(dummy_dataset)
-    assert "return type" in str(e)
-
-
-def test_wrapper_sets_gpuid(mocker: MockerFixture, dummy_dataset: DataSet):
-    mocker.patch("httomo.runner.backend_wrapper.gpu_enabled", True)
-
-    class FakeModule:
-        def fake_method(data, gpu_id: int):
-            assert gpu_id == 4
-            return data
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker), "mocked_module_path", "fake_method", MPI.COMM_WORLD
-    )
-
-    wrp.gpu_id = 4
-    wrp.execute(dummy_dataset)
-
-
-def test_wrapper_fails_for_gpumethods_with_no_gpu(mocker: MockerFixture):
-    mocker.patch("httomo.runner.backend_wrapper.gpu_enabled", False)
-
-    class FakeModule:
-        def fake_method(data):
-            return data
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-
-    with pytest.raises(ValueError) as e:
-        make_backend_wrapper(
-            make_mock_repo(mocker, implementation="gpu_cupy"),
-            "mocked_module_path",
-            "fake_method",
-            MPI.COMM_WORLD,
-        )
-
-    assert "GPU is not available" in str(e)
-
-
-def test_wrapper_passes_communicator_if_needed(
-    mocker: MockerFixture, dummy_dataset: DataSet
-):
-    class FakeModule:
-        def fake_method(data, comm: Optional[MPI.Comm] = None):
-            assert comm is not None
-            return data
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker), "mocked_module_path", "fake_method", MPI.COMM_WORLD
-    )
-
-    wrp.execute(dummy_dataset)
-
-
-def test_wrapper_allows_parameters_with_defaults(
-    mocker: MockerFixture, dummy_dataset: DataSet
-):
-    class FakeModule:
-        def fake_method(data, defaultpar: int = 10):
-            assert defaultpar == 10
-            return data
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker), "mocked_module_path", "fake_method", MPI.COMM_WORLD
-    )
-
-    wrp.execute(dummy_dataset)
-
-
-def test_wrapper_calculate_stats(mocker: MockerFixture, dummy_dataset: DataSet):
-    class FakeModule:
-        def calculate_stats(data):
-            return data
-        
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker),
-        "mocked_module_path.calculate_stats",
-        "calculate_stats",
-        MPI.COMM_WORLD,
-        output_mapping={"glob_stats": "glob_stats"},        
-    )
-    new_dataset = wrp.execute(dummy_dataset)
-
-    assert wrp.get_side_output() == {
-        "glob_stats": (1.0, 1.0, 1.0, 1000),
-    }
-
-def test_wrapper_build_kwargs_parameter_not_given(
-    mocker: MockerFixture, dummy_dataset: DataSet
-):
-    class FakeModule:
-        def fake_method(data, param):
-            return data
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker), "mocked_module_path", "fake_method", MPI.COMM_WORLD
-    )
-    with pytest.raises(ValueError) as e:
-        wrp.execute(dummy_dataset)
-
-    assert "Cannot map method parameter param to a value" in str(e)
-
-
-def test_wrapper_access_outputref_params(mocker: MockerFixture, dummy_dataset: DataSet):
-    class FakeModule:
-        def fake_method(data, param):
-            assert param == 42
-            return data
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker), "mocked_module_path", "fake_method", MPI.COMM_WORLD
-    )
-    m = make_test_method(mocker)
-    m.get_side_output.return_value = {"somepar": 42}
-    wrp["param"] = OutputRef(m, "somepar")
-
-    wrp.execute(dummy_dataset)
-
-
-def test_wrapper_access_outputref_params_kwargs(
-    mocker: MockerFixture, dummy_dataset: DataSet
-):
-    class FakeModule:
-        def fake_method(data, **kwargs):
-            assert kwargs["param"] == 42
-            return data
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker), "mocked_module_path", "fake_method", MPI.COMM_WORLD
-    )
-    m = make_test_method(mocker)
-    m.get_side_output.return_value = {"somepar": 42}
-    wrp["param"] = OutputRef(m, "somepar")
-
-    wrp.execute(dummy_dataset)
-
-
-def test_wrapper_different_data_parameter_name(
-    mocker: MockerFixture, dummy_dataset: DataSet
-):
-    class FakeModule:
-        def fake_method(array):
-            np.testing.assert_array_equal(array, 42)
-            return array
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker), "mocked_module_path", "fake_method", MPI.COMM_WORLD
-    )
-    dummy_dataset.data[:] = 42
-    wrp.execute(dummy_dataset)
-
-
-def test_wrapper_for_method_with_kwargs(mocker: MockerFixture, dummy_dataset: DataSet):
-    class FakeModule:
-        def fake_method(data, param, **kwargs):
-            assert param == 42.0
-            assert kwargs == {"extra": 123}
-            return data
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker),
-        "mocked_module_path",
-        "fake_method",
-        MPI.COMM_WORLD,
-        param=42.0,
-        extra=123,
-    )
-
-    wrp.execute(dummy_dataset)
-
-
-def test_wrapper_sets_config_params_constructor(
-    mocker: MockerFixture, dummy_dataset: DataSet
-):
-    class FakeModule:
-        def param_tester(data, param):
-            assert param == 42
-            return data
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker),
-        "mocked_module_path",
-        "param_tester",
-        MPI.COMM_WORLD,
-        param=42,
-    )
-    wrp.execute(dummy_dataset)
-
-    assert wrp["param"] == 42
-
-
-def test_wrapper_sets_config_params_setter(
-    mocker: MockerFixture, dummy_dataset: DataSet
-):
-    class FakeModule:
-        def param_tester(data, param):
-            assert param == 42
-            return data
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker),
-        "mocked_module_path",
-        "param_tester",
-        MPI.COMM_WORLD,
-    )
-    wrp["param"] = 42
-    wrp.execute(dummy_dataset)
-
-    assert wrp["param"] == 42
-
-
-def test_wrapper_sets_config_params_setter_not_in_arguments(
-    mocker: MockerFixture, dummy_dataset: DataSet
-):
-    class FakeModule:
-        def param_tester(data, param):
-            assert param == 42
-            return data
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker),
-        "mocked_module_path",
-        "param_tester",
-        MPI.COMM_WORLD,
-    )
-    with pytest.raises(ValueError):
-        wrp["param_not_existing"] = 42
-
-
-def test_wrapper_sets_config_params_append_dict(
-    mocker: MockerFixture, dummy_dataset: DataSet
-):
-    class FakeModule:
-        def param_tester(data, param1, param2):
-            assert param1 == 42
-            assert param2 == 43
-            return data
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker),
-        "mocked_module_path",
-        "param_tester",
-        MPI.COMM_WORLD,
-    )
-    wrp.append_config_params({"param1": 42, "param2": 43})
-    wrp.execute(dummy_dataset)
-
-    assert wrp["param1"] == 42
-    assert wrp["param2"] == 43
-
-
-def test_wrapper_passes_darks_flats_to_normalize(
-    mocker: MockerFixture, dummy_dataset: DataSet
-):
-    class FakeModule:
-        def normalize_tester(data, flats, darks):
-            np.testing.assert_array_equal(data, 1)
-            np.testing.assert_array_equal(darks, 2)
-            np.testing.assert_array_equal(flats, 3)
-            return data
-
-    importmock = mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker),
-        "mocked_module_path",
-        "normalize_tester",
-        MPI.COMM_WORLD,
-    )
-    dummy_dataset.unlock()
-    dummy_dataset.data[:] = 1
-    dummy_dataset.darks[:] = 2
-    dummy_dataset.flats[:] = 3
-    dummy_dataset.lock()
-
-    wrp.execute(dummy_dataset)
-
-    importmock.assert_called_once_with("mocked_module_path")
-
-@pytest.mark.parametrize("block", [False, True])
-def test_wrapper_handles_reconstruction_angle_reshape(
-    mocker: MockerFixture, dummy_dataset: DataSet, block: bool
-):
-    class FakeModule:
-        # we give the angles a different name on purpose
-        def recon_tester(data, theta):
-            np.testing.assert_array_equal(data, 1)
-            np.testing.assert_array_equal(theta, 2)
-            assert data.shape[0] == len(theta)
-            return data
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker),
-        "mocked_module_path.algorithm",
-        "recon_tester",
-        MPI.COMM_WORLD,
-    )
-    dummy_dataset.data[:] = 1
-    dummy_dataset.unlock()
-    dummy_dataset.angles[:] = 2
-    dummy_dataset.lock()
-    
-    input = dummy_dataset.make_block(0, 0, 3) if block else dummy_dataset
-
-    wrp.execute(input)
-
-
-def test_wrapper_handles_reconstruction_axisswap(
-    mocker: MockerFixture, dummy_dataset: DataSet
-):
-    class FakeModule:
-        def recon_tester(data, theta):
-            return data.swapaxes(0, 1)
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker),
-        "mocked_module_path.algorithm",
-        "recon_tester",
-        MPI.COMM_WORLD,
-    )
-    dummy_dataset.data = np.ones((13, 14, 15), dtype=np.float32)
-    res = wrp.execute(dummy_dataset)
-
-    assert res.data.shape == (13, 14, 15)
-
-
-def test_wrapper_gives_recon_algorithm(mocker: MockerFixture):
-    class FakeModule:
-        def recon_tester(data, algorithm, center):
-            return data
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker),
-        "test.algorithm",
-        "recon_tester",
-        MPI.COMM_WORLD,
-    )
-
-    assert wrp.recon_algorithm is None
-    wrp["algorithm"] = "testalgo"
-    assert wrp.recon_algorithm == "testalgo"
-
-
-def test_wrapper_gives_no_recon_algorithm_if_not_recon_method(mocker: MockerFixture):
-    class FakeModule:
-        def tester(data, algorithm):
-            return data
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker),
-        "test.something",
-        "tester",
-        MPI.COMM_WORLD,
-    )
-
-    assert wrp.recon_algorithm is None
-    wrp["algorithm"] = "testalgo"
-    assert wrp.recon_algorithm is None
-
-
-def test_wrapper_rotation_fails_with_projection_method(mocker: MockerFixture):
-    class FakeModule:
-        def rotation_tester(data, ind=None):
-            return 42.0
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    with pytest.raises(NotImplementedError):
-        make_backend_wrapper(
-            make_mock_repo(mocker, pattern=Pattern.projection),
-            "mocked_module_path.rotation",
-            "rotation_tester",
-            MPI.COMM_WORLD,
-        )
-
-
-def test_wrapper_rotation_accumulates_blocks(
-    mocker: MockerFixture, dummy_dataset: DataSet
-):
-    class FakeModule:
-        def rotation_tester(data, ind=None):
-            assert data.ndim == 2  # for 1 slice only
-            np.testing.assert_array_equal(data, dummy_dataset.data[:, 4, :])
-            return 42.0
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker),
-        "mocked_module_path.rotation",
-        "rotation_tester",
-        MPI.COMM_WORLD,
-        output_mapping={"cor": "cor"},
-    )
-    normalize = mocker.patch.object(
-        wrp, "normalize_sino", side_effect=lambda sino, flats, darks: sino
-    )
-    # generate varying numbers so the comparison above works
-    dummy_dataset.data = np.arange(dummy_dataset.data.size, dtype=np.float32).reshape(
-        dummy_dataset.shape
-    )
-    b1 = dummy_dataset.make_block(0, 0, dummy_dataset.shape[0] // 2)
-    b2 = dummy_dataset.make_block(
-        0, dummy_dataset.shape[0] // 2, dummy_dataset.shape[0] // 2
-    )
-    wrp.execute(b1)
-    normalize.assert_not_called()
-    wrp.execute(b2)
-    normalize.assert_called_once()
-    assert wrp.get_side_output() == {"cor": 42.0}
-
-
-@pytest.mark.parametrize("gpu", [False, True])
-@pytest.mark.parametrize("rank", [0, 1])
-@pytest.mark.parametrize("ind_par", ["mid", 2, None])
-def test_wrapper_rotation_gathers_single_sino_slice(
-    mocker: MockerFixture,
-    dummy_dataset: DataSet,
-    rank: int,
-    ind_par: Union[str, int, None],
-    gpu: bool
-):
-    class FakeModule:
-        def rotation_tester(data, ind=None):
-            assert rank == 0  # for rank 1, it shouldn't be called
-            assert data.ndim == 2  # for 1 slice only
-            assert ind == 0
-            if ind_par == "mid" or ind_par is None:
-                xp.testing.assert_array_equal(
-                    dummy_dataset.data[:, (dummy_dataset.data.shape[1] - 1) // 2, :],
-                    data,
-                )
-            else:
-                xp.testing.assert_array_equal(dummy_dataset.data[:, ind_par, :], data)
-            return 42.0
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker, implementation="gpu_cupy"),
-        "mocked_module_path.rotation",
-        "rotation_tester",
-        MPI.COMM_WORLD,
-    )
-    if ind_par is not None:
-        wrp["ind"] = ind_par
-    dummy_dataset.data = np.arange(dummy_dataset.data.size, dtype=np.float32).reshape(
-        dummy_dataset.shape
-    )
-    if gpu:
-        dummy_dataset.to_gpu()
-    mocker.patch.object(wrp, "_gather_sino_slice", side_effect=lambda s: wrp.sino)
-    normalize = mocker.patch.object(
-        wrp, "normalize_sino", side_effect=lambda sino, f, d: sino
-    )
-    comm = mocker.patch.object(wrp, "comm")
-    comm.rank = rank
-    comm.size = 2
-    comm.bcast.return_value = 42.0
-
-    res = wrp.execute(dummy_dataset)
-
-    assert wrp.pattern == Pattern.projection
-    xp.testing.assert_array_equal(res.data, dummy_dataset.data)
-    comm.bcast.assert_called_once()
-    if rank == 0:
-        normalize.assert_called_once()
-    else:
-        normalize.assert_not_called()
-
-
-@pytest.mark.parametrize("rank", [0, 1])
-def test_wrapper_gather_sino_slice(mocker: MockerFixture, rank: int):
-    class FakeModule:
-        def rotation_tester(data, ind=None):
-            return 42.0
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp: RotationWrapper = make_backend_wrapper(
-        make_mock_repo(mocker),
-        "mocked_module_path.rotation",
-        "rotation_tester",
-        MPI.COMM_WORLD,
-    )
-    if rank == 0:
-        wrp.sino = np.arange(2 * 6, dtype=np.float32).reshape((2, 6))
-    else:
-        wrp.sino = np.arange(2 * 6, 5 * 6, dtype=np.float32).reshape((3, 6))
-    comm = mocker.patch.object(wrp, "comm")
-    comm.rank = rank
-    comm.size = 2
-    if rank == 0:
-        comm.gather.return_value = [2 * 6, 3 * 6]
-    else:
-        comm.gather.return_value = [2 * 6]
-
-    res = wrp._gather_sino_slice((5, 13, 6))
-
-    comm.Gatherv.assert_called_once()
-    if rank == 0:
-        assert res.shape == (5, 6)
-        comm.gather.assert_called_once_with(2 * 6)
-    else:
-        assert res is None
-        comm.gather.assert_called_once_with(3 * 6)
-
-
-def test_wrapper_rotation_normalize_sino_no_darks_flats():
-    ret = RotationWrapper.normalize_sino(
-        np.ones((10, 10), dtype=np.float32), None, None
-    )
-
-    assert ret.shape == (10, 1, 10)
-    np.testing.assert_allclose(np.squeeze(ret), 1.0)
-
-
-def test_wrapper_rotation_normalize_sino_same_darks_flats():
-    ret = RotationWrapper.normalize_sino(
-        np.ones((10, 10), dtype=np.float32),
-        0.5
-        * np.ones(
-            (
-                10,
-                10,
-            ),
-            dtype=np.float32,
-        ),
-        0.5
-        * np.ones(
-            (
-                10,
-                10,
-            ),
-            dtype=np.float32,
-        ),
-    )
-
-    assert ret.shape == (10, 1, 10)
-    np.testing.assert_allclose(ret, 0.5)
-
-
-def test_wrapper_rotation_normalize_sino_scalar():
-    ret = RotationWrapper.normalize_sino(
-        np.ones((10, 10), dtype=np.float32),
-        0.5
-        * np.ones(
-            (
-                10,
-                10,
-            ),
-            dtype=np.float32,
-        ),
-        0.5
-        * np.ones(
-            (
-                10,
-                10,
-            ),
-            dtype=np.float32,
-        ),
-    )
-
-    assert ret.shape == (10, 1, 10)
-    np.testing.assert_allclose(ret, 0.5)
-
-
-def test_wrapper_rotation_normalize_sino_different_darks_flats():
-    ret = RotationWrapper.normalize_sino(
-        2.0 * np.ones((10, 10), dtype=np.float32),
-        1.0 * np.ones((10, 10), dtype=np.float32),
-        0.5 * np.ones((10, 10), dtype=np.float32),
-    )
-
-    assert ret.shape == (10, 1, 10)
-    np.testing.assert_allclose(np.squeeze(ret), 1.0)
-
-@pytest.mark.skipif(
-    not gpu_enabled or xp.cuda.runtime.getDeviceCount() == 0,
-    reason="skipped as cupy is not available",
-)
-@pytest.mark.cupy
-def test_wrapper_rotation_normalize_sino_different_darks_flats_gpu():
-    ret = RotationWrapper.normalize_sino(
-        2.0 * xp.ones((10, 10), dtype=np.float32),
-        1.0 * xp.ones((10, 10), dtype=np.float32),
-        0.5 * xp.ones((10, 10), dtype=np.float32),
-    )
-
-    assert ret.shape == (10, 1, 10)
-    assert getattr(ret, "device", None) is not None
-    xp.testing.assert_allclose(xp.squeeze(ret), 1.0)
-
-
-
-def test_wrapper_rotation_180(mocker: MockerFixture, dummy_dataset: DataSet):
-    class FakeModule:
-        def rotation_tester(data, ind):
-            return 42.0  # center of rotation
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker),
-        "mocked_module_path.rotation",
-        "rotation_tester",
-        MPI.COMM_WORLD,
-        output_mapping={"cor": "center"},
-        ind=5,
-    )
-
-    new_dataset = wrp.execute(dummy_dataset)
-
-    assert wrp.get_side_output() == {"center": 42.0}
-    assert new_dataset == dummy_dataset  # note: not a deep comparison
-
-
-def test_wrapper_rotation_360(mocker: MockerFixture, dummy_dataset: DataSet):
-    class FakeModule:
-        def rotation_tester(data, ind):
-            # cor, overlap, side, overlap_position - from find_center_360
-            return 42.0, 3.0, 1, 10.0
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker),
-        "mocked_module_path.rotation",
-        "rotation_tester",
-        MPI.COMM_WORLD,
-        output_mapping={
-            "cor": "center",
-            "overlap": "overlap",
-            "overlap_position": "pos",
-        },
-        ind=5,
-    )
-    new_dataset = wrp.execute(dummy_dataset)
-
-    assert wrp.get_side_output() == {
-        "center": 42.0,
-        "overlap": 3.0,
-        "pos": 10.0,
-    }
-    assert new_dataset == dummy_dataset  # note: not a deep comparison
-
-
-def test_wrapper_dezinging(mocker: MockerFixture, dummy_dataset: DataSet):
-    class FakeModule:
-        def remove_outlier3d(x):
-            return 2 * x
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker),
-        "mocked_module_path.prep",
-        "remove_outlier3d",
-        MPI.COMM_WORLD,
-    )
-    dummy_dataset.unlock()
-    dummy_dataset.data[:] = 1
-    dummy_dataset.flats[:] = 3
-    dummy_dataset.darks[:] = 4
-    dummy_dataset.lock()
-
-    newset = wrp.execute(dummy_dataset)
-
-    # we double them all, so we expect all 3 to be twice the input
-    np.testing.assert_array_equal(newset.data, 2)
-    np.testing.assert_array_equal(newset.flats, 6)
-    np.testing.assert_array_equal(newset.darks, 8)
-
-    # it should only update the darks/flats once, but still apply to the data
-    newset = wrp.execute(newset)
-    np.testing.assert_array_equal(newset.data, 4)
-    np.testing.assert_array_equal(newset.flats, 6)
-    np.testing.assert_array_equal(newset.darks, 8)
-
-
-def test_wrapper_save_to_images(mocker: MockerFixture, dummy_dataset: DataSet):
-    class FakeModule:
-        def save_to_images(data, out_dir, comm_rank, axis, file_format):
-            np.testing.assert_array_equal(data, 1)
-            assert out_dir == httomo.globals.run_out_dir
-            assert comm_rank == MPI.COMM_WORLD.rank
-            assert axis == 1
-            assert file_format == "tif"
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker, implementation="cpu"),
-        "mocked_module_path.images",
-        "save_to_images",
-        MPI.COMM_WORLD,
-        axis=1,
-        file_format="tif",
-    )
-    newset = wrp.execute(dummy_dataset)
-
-    assert newset == dummy_dataset
-
-
-@pytest.mark.skipif(
-    not gpu_enabled or xp.cuda.runtime.getDeviceCount() == 0,
-    reason="skipped as cupy is not available",
-)
-@pytest.mark.cupy
-def test_wrapper_images_leaves_gpudata(mocker: MockerFixture, dummy_dataset: DataSet):
-    class FakeModule:
-        def save_to_images(data, out_dir, comm_rank):
-            assert getattr(data, "device", None) is None  # make sure it's on CPU
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    wrp = make_backend_wrapper(
-        make_mock_repo(mocker),
-        "mocked_module_path.images",
-        "save_to_images",
-        MPI.COMM_WORLD,
-    )
-    with xp.cuda.Device(0):
-        dummy_dataset.to_gpu()
-        new_dataset = wrp.execute(dummy_dataset)
-
-        assert new_dataset.is_gpu is True
-
-
-@pytest.mark.parametrize(
-    "implementation,is_cpu,is_gpu,cupyrun",
-    [
-        ("cpu", True, False, False),
-        ("gpu", False, True, False),
-        ("gpu_cupy", False, True, True),
-    ],
-)
-def test_wrapper_method_queries(
-    mocker: MockerFixture,
-    implementation: str,
-    is_cpu: bool,
-    is_gpu: bool,
-    cupyrun: bool,
-):
-    class FakeModule:
-        def test_method(data):
-            return data
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-
-    memory_gpu = [GpuMemoryRequirement(dataset="tomo", multiplier=1.2, method="direct")]
-    wrp = make_backend_wrapper(
-        make_mock_repo(
-            mocker,
-            pattern=Pattern.projection,
-            output_dims_change=True,
-            implementation=implementation,
-            memory_gpu=memory_gpu,
-        ),
-        "mocked_module_path",
-        "test_method",
-        MPI.COMM_WORLD,
-    )
-
-    assert wrp.pattern == Pattern.projection
-    assert wrp.output_dims_change is True
-    assert wrp.implementation == implementation
-    assert wrp.is_cpu == is_cpu
-    assert wrp.is_gpu == is_gpu
-    assert wrp.cupyrun == cupyrun
-    assert wrp.memory_gpu == memory_gpu
-
-
-@pytest.mark.parametrize("implementation", ["cpu", "gpu", "gpu_cupy"])
-@pytest.mark.parametrize(
-    "memory_gpu",
-    [[GpuMemoryRequirement(dataset="tomo", multiplier=2.0, method="direct")], 
-     [GpuMemoryRequirement(dataset="tomo", multiplier=0.0, method="direct")],
-     []],
-)
-def test_wrapper_calculate_max_slices_direct(
-    mocker: MockerFixture,
-    dummy_dataset: DataSet,
-    implementation: str,
-    memory_gpu: List[GpuMemoryRequirement],
-):
-    class FakeModule:
-        def test_method(data):
-            return data
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-
-    wrp = make_backend_wrapper(
-        make_mock_repo(
-            mocker,
-            pattern=Pattern.projection,
-            output_dims_change=True,
-            implementation=implementation,
-            memory_gpu=memory_gpu,
-        ),
-        "mocked_module_path",
-        "test_method",
-        MPI.COMM_WORLD,
-    )
-    shape = list(dummy_dataset.data.shape)
-    shape.pop(0)
-    databytes = shape[0] * shape[1] * dummy_dataset.data.itemsize
-    max_slices_expected = 5
-    multiplier = memory_gpu[0].multiplier if memory_gpu != [] else 1
-    available_memory_in = int(databytes * max_slices_expected * multiplier)
-    if available_memory_in == 0:
-        available_memory_in = 5
-    max_slices, available_memory = wrp.calculate_max_slices(
-        dummy_dataset, shape, available_memory_in
-    )
-
-    if gpu_enabled and implementation != "cpu":
-        assert max_slices == max_slices_expected
-    else:
-        assert max_slices > dummy_dataset.data.shape[0]
-    assert available_memory == available_memory_in
-
-
-def test_wrapper_calculate_max_slices_direct_flats_darks(
-    mocker: MockerFixture, dummy_dataset: DataSet
-):
-    class FakeModule:
-        def test_method(data):
-            return data
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-    memory_gpu = [
-        GpuMemoryRequirement(dataset="tomo", multiplier=2.0, method="direct"),
-        GpuMemoryRequirement(dataset="flats", multiplier=1.1, method="direct"),
-        GpuMemoryRequirement(dataset="darks", multiplier=1.2, method="direct"),
-    ]
-    wrp = make_backend_wrapper(
-        make_mock_repo(
-            mocker,
-            pattern=Pattern.projection,
-            output_dims_change=True,
-            implementation="gpu_cupy",
-            memory_gpu=memory_gpu,
-        ),
-        "mocked_module_path",
-        "test_method",
-        MPI.COMM_WORLD,
-    )
-    shape = list(dummy_dataset.data.shape)
-    shape.pop(0)
-    databytes = shape[0] * shape[1] * dummy_dataset.data.itemsize
-    max_slices_expected = 5
-    multiplier = memory_gpu[0].multiplier
-    available_memory_in = int(databytes * max_slices_expected * multiplier)
-    available_memory_adjusted = (
-        available_memory_in
-        + dummy_dataset.flats.nbytes * memory_gpu[1].multiplier
-        + dummy_dataset.darks.nbytes * memory_gpu[2].multiplier
-    )
-    max_slices, available_memory = wrp.calculate_max_slices(
-        dummy_dataset, shape, available_memory_adjusted
-    )
-
-    if gpu_enabled:
-        assert max_slices == max_slices_expected
-    else:
-        assert max_slices > dummy_dataset.data.shape[0]
-    assert available_memory == available_memory_in
-
-
-def test_wrapper_calculate_max_slices_module(
-    mocker: MockerFixture, dummy_dataset: DataSet
-):
-    class FakeModule:
-        def test_method(data):
-            return data
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-
-    memory_gpu = [
-        GpuMemoryRequirement(dataset="tomo", multiplier=None, method="module")
-    ]
-    wrp = make_backend_wrapper(
-        make_mock_repo(
-            mocker,
-            pattern=Pattern.projection,
-            output_dims_change=True,
-            implementation="gpu_cupy",
-            memory_gpu=memory_gpu,
-        ),
-        "mocked_module_path",
-        "test_method",
-        MPI.COMM_WORLD,
-    )
-    memcalc_mock = mocker.patch.object(
-        wrp.query, "calculate_memory_bytes", return_value=(1234, 5678)
-    )
-    shape = list(dummy_dataset.data.shape)
-    shape.pop(0)
-    max_slices, available_memory = wrp.calculate_max_slices(
-        dummy_dataset, tuple(shape), 1_000_000_000
-    )
-
-    if gpu_enabled:
-        assert max_slices == (1_000_000_000 - 5678) // 1234
-        assert available_memory == 1_000_000_000
-        memcalc_mock.assert_called_once_with(tuple(shape), dummy_dataset.data.dtype)
-    else:
-        assert max_slices > dummy_dataset.data.shape[0]
-        assert available_memory == 1_000_000_000
-
-
-def test_wrapper_calculate_output_dims(mocker: MockerFixture):
-    class FakeModule:
-        def test_method(data, testparam):
-            return data
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-
-    memory_gpu = []
-    wrp = make_backend_wrapper(
-        make_mock_repo(
-            mocker,
-            pattern=Pattern.projection,
-            output_dims_change=True,
-            implementation="gpu_cupy",
-            memory_gpu=memory_gpu,
-        ),
-        "mocked_module_path",
-        "test_method",
-        MPI.COMM_WORLD,
-    )
-    wrp["testparam"] = 32
-
-    memcalc_mock = mocker.patch.object(
-        wrp.query, "calculate_output_dims", return_value=(1234, 5678)
-    )
-
-    dims = wrp.calculate_output_dims((10, 10))
-
-    assert dims == (1234, 5678)
-    memcalc_mock.assert_called_with((10, 10), testparam=32)
-
-
-def test_wrapper_calculate_output_dims_no_change(mocker: MockerFixture):
-    class FakeModule:
-        def test_method(data):
-            return data
-
-    mocker.patch("importlib.import_module", return_value=FakeModule)
-
-    memory_gpu = []
-    wrp = make_backend_wrapper(
-        make_mock_repo(
-            mocker,
-            pattern=Pattern.projection,
-            output_dims_change=False,
-            implementation="gpu_cupy",
-            memory_gpu=memory_gpu,
-        ),
-        "mocked_module_path",
-        "test_method",
-        MPI.COMM_WORLD,
-    )
-
-    dims = wrp.calculate_output_dims((10, 10))
-
-    assert dims == (10, 10)
diff --git a/tests/runner/testing_utils.py b/tests/runner/testing_utils.py
deleted file mode 100644
index 04921d8ec..000000000
--- a/tests/runner/testing_utils.py
+++ /dev/null
@@ -1,59 +0,0 @@
-from httomo.runner.backend_wrapper import BackendWrapper
-from httomo.runner.loader import LoaderInterface
-from httomo.runner.methods_repository_interface import MethodRepository
-from httomo.utils import Pattern
-
-from pytest_mock import MockerFixture
-
-
-def make_test_method(
-    mocker: MockerFixture,
-    gpu=False,
-    pattern=Pattern.projection,
-    method_name="testmethod",
-    module_path="testpath",
-    **kwargs,
-) -> BackendWrapper:
-    mock = mocker.create_autospec(
-        BackendWrapper,
-        instance=True,
-        method_name=method_name,
-        module_path=module_path,
-        pattern=pattern,
-        is_gpu=gpu,
-        is_cpu=not gpu,
-        config_params=kwargs,
-        __getitem__=lambda _, k: kwargs[k],  # return kwargs value from dict access
-    )
-
-    return mock
-
-
-def make_test_loader(
-    mocker: MockerFixture, pattern: Pattern = Pattern.all, method_name="testloader"
-) -> LoaderInterface:
-    return mocker.create_autospec(
-        LoaderInterface, instance=True, pattern=pattern, method_name=method_name
-    )
-
-
-def make_mock_repo(
-    mocker: MockerFixture,
-    pattern=Pattern.sinogram,
-    output_dims_change=False,
-    implementation="cpu",
-    memory_gpu={"datasets": ["tomo"], "multipliers": [1.2], "methods": ["direct"]},
-) -> MethodRepository:
-    """Makes a mock MethodRepository that returns the given properties on any query"""
-    mock_repo = mocker.MagicMock()
-    mock_query = mocker.MagicMock()
-    mocker.patch.object(mock_repo, "query", return_value=mock_query)
-    mocker.patch.object(mock_query, "get_pattern", return_value=pattern)
-    mocker.patch.object(
-        mock_query, "get_output_dims_change", return_value=output_dims_change
-    )
-    mocker.patch.object(mock_query, "get_implementation", return_value=implementation)
-    mocker.patch.object(mock_query, "get_memory_gpu_params", return_value=memory_gpu)
-    return mock_repo
-
-
diff --git a/tests/test_backends/test_httomolibgpu.py b/tests/test_backends/test_httomolibgpu.py
index 0adbaa247..961f6af08 100644
--- a/tests/test_backends/test_httomolibgpu.py
+++ b/tests/test_backends/test_httomolibgpu.py
@@ -1,5 +1,6 @@
 import dataclasses
 import inspect
+from typing import Literal
 import pytest
 from mpi4py import MPI
 import numpy as np
@@ -14,28 +15,46 @@
 
 from httomo.methods_database.query import get_method_info
 
+
+from httomolibgpu.misc.morph import data_resampler
 from httomolibgpu.prep.normalize import normalize
 from httomolibgpu.prep.phase import paganin_filter_tomopy, paganin_filter_savu
 from httomolibgpu.prep.alignment import distortion_correction_proj_discorpy
+from httomolibgpu.prep.stripe import (
+    remove_stripe_based_sorting,
+    remove_stripe_ti,
+    remove_all_stripe,
+)
 from httomolibgpu.prep.stripe import remove_stripe_based_sorting, remove_stripe_ti
+from httomolibgpu.misc.corr import remove_outlier
 from httomolibgpu.recon.algorithm import FBP, SIRT, CGLS
+from httomolibgpu.misc.rescale import rescale_to_int
 
+from httomo.methods_database.packages.external.httomolibgpu.supporting_funcs.misc.morph import *
 from httomo.methods_database.packages.external.httomolibgpu.supporting_funcs.prep.phase import *
 from httomo.methods_database.packages.external.httomolibgpu.supporting_funcs.prep.stripe import *
 from httomo.methods_database.packages.external.httomolibgpu.supporting_funcs.recon.algorithm import *
+from httomo.methods_database.packages.external.httomolibgpu.supporting_funcs.misc.rescale import *
+from httomo.methods_database.packages.external.httomolibgpu.supporting_funcs.prep.normalize import *
 
 module_mem_path = "httomo.methods_database.packages.external."
+
+
 class MaxMemoryHook(cp.cuda.MemoryHook):
-    
+
     def __init__(self, initial=0):
         self.max_mem = initial
         self.current = initial
-    
-    def malloc_postprocess(self, device_id: int, size: int, mem_size: int, mem_ptr: int, pmem_id: int):
+
+    def malloc_postprocess(
+        self, device_id: int, size: int, mem_size: int, mem_ptr: int, pmem_id: int
+    ):
         self.current += mem_size
         self.max_mem = max(self.max_mem, self.current)
 
-    def free_postprocess(self, device_id: int, mem_size: int, mem_ptr: int, pmem_id: int):
+    def free_postprocess(
+        self, device_id: int, mem_size: int, mem_ptr: int, pmem_id: int
+    ):
         self.current -= mem_size
 
     def alloc_preprocess(self, **kwargs):
@@ -43,101 +62,119 @@ def alloc_preprocess(self, **kwargs):
 
     def alloc_postprocess(self, device_id: int, mem_size: int, mem_ptr: int):
         pass
-    
+
     def free_preprocess(self, **kwargs):
         pass
 
     def malloc_preprocess(self, **kwargs):
         pass
 
+
+@pytest.mark.parametrize("dtype", ["uint16", "float32"])
+@pytest.mark.parametrize("slices", [50, 121])
 @pytest.mark.cupy
-def test_normalize_memoryhook(data, flats, darks, ensure_clean_memory):
+def test_normalize_memoryhook(flats, darks, ensure_clean_memory, dtype, slices):
     hook = MaxMemoryHook()
+    data = cp.random.random_sample(
+        (slices, flats.shape[1], flats.shape[2]), dtype=np.float32
+    )
+    if dtype == "uint16":
+        darks = (darks * 1233).astype(np.uint16)
+        flats = flats.astype(np.uint16)
+        data = data.astype(np.uint16)
     with hook:
-        data_normalize = normalize(cp.copy(data), flats, darks, minus_log=True).get()
+        normalize(cp.copy(data), flats, darks, minus_log=True).get()
 
     # make sure estimator function is within range (80% min, 100% max)
-    max_mem = hook.max_mem # the amount of memory in bytes needed for the method according to memoryhook
-    max_mem_mb = round(max_mem / (1024**2), 2) # now in mbs
-    
+    max_mem = (
+        hook.max_mem
+    )  # the amount of memory in bytes needed for the method according to memoryhook
+
     # now we estimate how much of the total memory required for this data
-    library_info = get_method_info("httomolibgpu.prep.normalize", "normalize", "memory_gpu")
-    for i, dst in enumerate(library_info[0]['datasets']):
-        if dst == "flats":
-            flats_bytes = library_info[1]['multipliers'][i] * np.prod(cp.shape(flats)) * float32().nbytes
-        elif dst == "darks":
-            darks_bytes = library_info[1]['multipliers'][i] * np.prod(cp.shape(darks)) * float32().nbytes
-        else:            
-            data_bytes = library_info[1]['multipliers'][i] * np.prod(cp.shape(data)) * float32().nbytes
-    estimated_memory_bytes = flats_bytes + darks_bytes + data_bytes
-    estimated_memory_mb = round(estimated_memory_bytes / (1024**2), 2)
-    # now compare both memory estimations 
+    (estimated_memory_bytes, subtract_bytes) = _calc_memory_bytes_normalize(
+        data.shape[1:], dtype=data.dtype
+    )
+
+    estimated_memory_mb = round(slices * estimated_memory_bytes / (1024**2), 2)
+    max_mem -= subtract_bytes
+    max_mem_mb = round(max_mem / (1024**2), 2)
+
+    # now compare both memory estimations
     difference_mb = abs(estimated_memory_mb - max_mem_mb)
-    percents_relative_maxmem = round((difference_mb/max_mem_mb)*100)
+    percents_relative_maxmem = round((difference_mb / max_mem_mb) * 100)
     # the estimated_memory_mb should be LARGER or EQUAL to max_mem_mb
-    # the resulting percent value should not deviate from max_mem on more than 20%    
-    assert estimated_memory_mb >= max_mem_mb 
+    # the resulting percent value should not deviate from max_mem on more than 20%
+    assert estimated_memory_mb >= max_mem_mb
     assert percents_relative_maxmem <= 20
-   
+
+
+@pytest.mark.parametrize("dtype", ["uint16"])
+@pytest.mark.parametrize("slices", [151, 321])
 @pytest.mark.cupy
-@pytest.mark.parametrize("slices", [128, 256, 512])
-def test_normalize_memoryhook_parametrise(slices, ensure_clean_memory):
-    data_size_dim = 512
-    data = cp.random.random_sample((slices, data_size_dim, data_size_dim), dtype=np.float32)
-    darks = cp.random.random_sample((20, data_size_dim, data_size_dim), dtype=np.float32)
-    flats = cp.random.random_sample((20, data_size_dim, data_size_dim), dtype=np.float32)
+def test_remove_outlier_memoryhook(flats, ensure_clean_memory, dtype, slices):
     hook = MaxMemoryHook()
+    data = cp.random.random_sample(
+        (slices, flats.shape[1], flats.shape[2]), dtype=np.float32
+    )
+    if dtype == "uint16":
+        data = data.astype(np.uint16)
     with hook:
-        data_normalize = normalize(cp.copy(data), flats, darks, minus_log=True).get()
+        remove_outlier(cp.copy(data))
 
     # make sure estimator function is within range (80% min, 100% max)
-    max_mem = hook.max_mem # the amount of memory in bytes needed for the method according to memoryhook
-    max_mem_mb = round(max_mem / (1024**2), 2) # now in mbs
-   
+    max_mem = (
+        hook.max_mem
+    )  # the amount of memory in bytes needed for the method according to memoryhook
+
     # now we estimate how much of the total memory required for this data
-    library_info = get_method_info("httomolibgpu.prep.normalize", "normalize", "memory_gpu")
-    for i, dst in enumerate(library_info[0]['datasets']):
-        if dst == "flats":
-            flats_bytes = library_info[1]['multipliers'][i] * np.prod(cp.shape(flats)) * float32().nbytes
-        elif dst == "darks":
-            darks_bytes = library_info[1]['multipliers'][i] * np.prod(cp.shape(darks)) * float32().nbytes
-        else:            
-            data_bytes = library_info[1]['multipliers'][i] * np.prod(cp.shape(data)) * float32().nbytes
-    estimated_memory_bytes = flats_bytes + darks_bytes + data_bytes
+    library_info = get_method_info(
+        "httomolibgpu.misc.corr", "remove_outlier", "memory_gpu"
+    )
+    estimated_memory_bytes = (
+        library_info[1]["multipliers"][0] * np.prod(cp.shape(data)) * uint16().nbytes
+    )
+
     estimated_memory_mb = round(estimated_memory_bytes / (1024**2), 2)
-    # now compare both memory estimations 
+
+    max_mem_mb = round(max_mem / (1024**2), 2)
+    # now compare both memory estimations
     difference_mb = abs(estimated_memory_mb - max_mem_mb)
-    percents_relative_maxmem = round((difference_mb/max_mem_mb)*100)
+    percents_relative_maxmem = round((difference_mb / max_mem_mb) * 100)
     # the estimated_memory_mb should be LARGER or EQUAL to max_mem_mb
-    # the resulting percent value should not deviate from max_mem on more than 20%    
-    assert estimated_memory_mb >= max_mem_mb 
+    # the resulting percent value should not deviate from max_mem on more than 20%
+    assert estimated_memory_mb >= max_mem_mb
     assert percents_relative_maxmem <= 20
 
+
 @pytest.mark.cupy
 @pytest.mark.parametrize("slices", [64, 128])
 @pytest.mark.parametrize("dim_x", [81, 260, 320])
 @pytest.mark.parametrize("dim_y", [340, 135, 96])
-def test_paganin_filter_tomopy_memoryhook(slices, dim_x, dim_y, ensure_clean_memory):    
+def test_paganin_filter_tomopy_memoryhook(slices, dim_x, dim_y, ensure_clean_memory):
     data = cp.random.random_sample((slices, dim_x, dim_y), dtype=np.float32)
     hook = MaxMemoryHook()
     with hook:
         data_filtered = paganin_filter_tomopy(cp.copy(data)).get()
 
     # make sure estimator function is within range (80% min, 100% max)
-    max_mem = hook.max_mem # the amount of memory in bytes needed for the method according to memoryhook   
-    
+    max_mem = (
+        hook.max_mem
+    )  # the amount of memory in bytes needed for the method according to memoryhook
+
     # now we estimate how much of the total memory required for this data
-    (estimated_memory_bytes, subtract_bytes) = _calc_memory_bytes_paganin_filter_tomopy((dim_x, dim_y), dtype=np.float32())
-    estimated_memory_mb = round(slices*estimated_memory_bytes / (1024**2), 2)
+    (estimated_memory_bytes, subtract_bytes) = _calc_memory_bytes_paganin_filter_tomopy(
+        (dim_x, dim_y), dtype=np.float32()
+    )
+    estimated_memory_mb = round(slices * estimated_memory_bytes / (1024**2), 2)
     max_mem -= subtract_bytes
     max_mem_mb = round(max_mem / (1024**2), 2)
-    
-    # now we compare both memory estimations 
+
+    # now we compare both memory estimations
     difference_mb = abs(estimated_memory_mb - max_mem_mb)
-    percents_relative_maxmem = round((difference_mb/max_mem_mb)*100)
+    percents_relative_maxmem = round((difference_mb / max_mem_mb) * 100)
     # the estimated_memory_mb should be LARGER or EQUAL to max_mem_mb
-    # the resulting percent value should not deviate from max_mem on more than 20%    
-    assert estimated_memory_mb >= max_mem_mb 
+    # the resulting percent value should not deviate from max_mem on more than 20%
+    assert estimated_memory_mb >= max_mem_mb
     assert percents_relative_maxmem <= 20
 
 
@@ -145,7 +182,7 @@ def test_paganin_filter_tomopy_memoryhook(slices, dim_x, dim_y, ensure_clean_mem
 @pytest.mark.parametrize("slices", [64, 128])
 @pytest.mark.parametrize("dim_x", [81, 260, 320])
 @pytest.mark.parametrize("dim_y", [340, 135, 96])
-def test_paganin_filter_savu_memoryhook(slices, dim_x, dim_y, ensure_clean_memory):    
+def test_paganin_filter_savu_memoryhook(slices, dim_x, dim_y, ensure_clean_memory):
     data = cp.random.random_sample((slices, dim_x, dim_y), dtype=np.float32)
     kwargs = {}
     kwargs["ratio"] = 250.0
@@ -154,93 +191,124 @@ def test_paganin_filter_savu_memoryhook(slices, dim_x, dim_y, ensure_clean_memor
     kwargs["resolution"] = 1.28
     kwargs["pad_x"] = 20
     kwargs["pad_y"] = 20
-    kwargs["pad_method"] = 'edge'
+    kwargs["pad_method"] = "edge"
     kwargs["increment"] = 0.0
     hook = MaxMemoryHook()
     with hook:
-        data_filtered = paganin_filter_savu(cp.copy(data),
-                                            **kwargs).get()
+        data_filtered = paganin_filter_savu(cp.copy(data), **kwargs).get()
 
     # make sure estimator function is within range (80% min, 100% max)
-    max_mem = hook.max_mem # the amount of memory in bytes needed for the method according to memoryhook   
-    
+    max_mem = (
+        hook.max_mem
+    )  # the amount of memory in bytes needed for the method according to memoryhook
+
     # now we estimate how much of the total memory required for this data
-    (estimated_memory_bytes, subtract_bytes) = _calc_memory_bytes_paganin_filter_savu((dim_x, dim_y), np.float32(), **kwargs)
-    estimated_memory_mb = round(slices*estimated_memory_bytes / (1024**2), 2)
+    (estimated_memory_bytes, subtract_bytes) = _calc_memory_bytes_paganin_filter_savu(
+        (dim_x, dim_y), np.float32(), **kwargs
+    )
+    estimated_memory_mb = round(slices * estimated_memory_bytes / (1024**2), 2)
     max_mem -= subtract_bytes
     max_mem_mb = round(max_mem / (1024**2), 2)
-    
-    # now we compare both memory estimations 
+
+    # now we compare both memory estimations
     difference_mb = abs(estimated_memory_mb - max_mem_mb)
-    percents_relative_maxmem = round((difference_mb/max_mem_mb)*100)
+    percents_relative_maxmem = round((difference_mb / max_mem_mb) * 100)
     # the estimated_memory_mb should be LARGER or EQUAL to max_mem_mb
-    # the resulting percent value should not deviate from max_mem on more than 20%    
-    assert estimated_memory_mb >= max_mem_mb 
+    # the resulting percent value should not deviate from max_mem on more than 20%
+    assert estimated_memory_mb >= max_mem_mb
     assert percents_relative_maxmem <= 20
-    
-    
+
+
 @pytest.mark.cupy
 @pytest.mark.parametrize("slices", [128, 190, 256])
-def test_distortion_correction_memoryhook(slices, distortion_correction_path, ensure_clean_memory):
+def test_distortion_correction_memoryhook(
+    slices, distortion_correction_path, ensure_clean_memory
+):
     data_size_dim = 320
-    data = cp.random.random_sample((slices, data_size_dim, data_size_dim), dtype=np.float32)
-    
+    data = cp.random.random_sample(
+        (slices, data_size_dim, data_size_dim), dtype=np.float32
+    )
+
     distortion_coeffs_path = os.path.join(
         distortion_correction_path, "distortion-coeffs.txt"
     )
-    preview = {"starts": [0, 0], "stops": [data.shape[1], data.shape[2]], "steps": [1, 1]}
-    
+    preview = {
+        "starts": [0, 0],
+        "stops": [data.shape[1], data.shape[2]],
+        "steps": [1, 1],
+    }
+
     hook = MaxMemoryHook()
     with hook:
-        data_corrected = distortion_correction_proj_discorpy(cp.copy(data), 
-                                                             distortion_coeffs_path,
-                                                             preview).get()
+        data_corrected = distortion_correction_proj_discorpy(
+            cp.copy(data), distortion_coeffs_path, preview
+        ).get()
 
     # make sure estimator function is within range (80% min, 100% max)
-    max_mem = hook.max_mem # the amount of memory in bytes needed for the method according to memoryhook
-    max_mem_mb = round(max_mem / (1024**2), 2) # now in mbs
-    
+    max_mem = (
+        hook.max_mem
+    )  # the amount of memory in bytes needed for the method according to memoryhook
+    max_mem_mb = round(max_mem / (1024**2), 2)  # now in mbs
+
     # now we estimate how much of the total memory required for this data
-    library_info = get_method_info("httomolibgpu.prep.alignment", "distortion_correction_proj_discorpy", "memory_gpu")
-    estimated_memory_bytes = library_info[1]['multipliers'][0] * np.prod(cp.shape(data)) * float32().nbytes
+    library_info = get_method_info(
+        "httomolibgpu.prep.alignment",
+        "distortion_correction_proj_discorpy",
+        "memory_gpu",
+    )
+    estimated_memory_bytes = (
+        library_info[1]["multipliers"][0] * np.prod(cp.shape(data)) * float32().nbytes
+    )
     estimated_memory_mb = round(estimated_memory_bytes / (1024**2), 2)
-    # now we compare both memory estimations 
+    # now we compare both memory estimations
     difference_mb = abs(estimated_memory_mb - max_mem_mb)
-    percents_relative_maxmem = round((difference_mb/max_mem_mb)*100)
+    percents_relative_maxmem = round((difference_mb / max_mem_mb) * 100)
     # the estimated_memory_mb should be LARGER or EQUAL to max_mem_mb
-    # the resulting percent value should not deviate from max_mem on more than 20%    
-    assert estimated_memory_mb >= max_mem_mb 
+    # the resulting percent value should not deviate from max_mem on more than 20%
+    assert estimated_memory_mb >= max_mem_mb
     assert percents_relative_maxmem <= 20
-    
+
+
 @pytest.mark.cupy
 @pytest.mark.parametrize("slices", [128, 256, 320])
-def test_remove_stripe_based_sorting_memoryhook(slices, distortion_correction_path, ensure_clean_memory):
+def test_remove_stripe_based_sorting_memoryhook(
+    slices, distortion_correction_path, ensure_clean_memory
+):
     data_size_dim = 300
-    data = cp.random.random_sample((data_size_dim, slices, data_size_dim), dtype=np.float32)
-    
+    data = cp.random.random_sample(
+        (data_size_dim, slices, data_size_dim), dtype=np.float32
+    )
+
     hook = MaxMemoryHook()
     with hook:
         data_filtered = remove_stripe_based_sorting(cp.copy(data)).get()
 
     # make sure estimator function is within range (80% min, 100% max)
-    max_mem = hook.max_mem # the amount of memory in bytes needed for the method according to memoryhook
-    max_mem_mb = round(max_mem / (1024**2), 2) # now in mbs
-    
+    max_mem = (
+        hook.max_mem
+    )  # the amount of memory in bytes needed for the method according to memoryhook
+    max_mem_mb = round(max_mem / (1024**2), 2)  # now in mbs
+
     # now we estimate how much of the total memory required for this data
-    library_info = get_method_info("httomolibgpu.prep.stripe", "remove_stripe_based_sorting", "memory_gpu")
-    estimated_memory_bytes = library_info[1]['multipliers'][0] * np.prod(cp.shape(data)) * float32().nbytes
+    library_info = get_method_info(
+        "httomolibgpu.prep.stripe", "remove_stripe_based_sorting", "memory_gpu"
+    )
+    estimated_memory_bytes = (
+        library_info[1]["multipliers"][0] * np.prod(cp.shape(data)) * float32().nbytes
+    )
     estimated_memory_mb = round(estimated_memory_bytes / (1024**2), 2)
-    # now we compare both memory estimations 
+    # now we compare both memory estimations
     difference_mb = abs(estimated_memory_mb - max_mem_mb)
-    percents_relative_maxmem = round((difference_mb/max_mem_mb)*100)
+    percents_relative_maxmem = round((difference_mb / max_mem_mb) * 100)
     # the estimated_memory_mb should be LARGER or EQUAL to max_mem_mb
-    # the resulting percent value should not deviate from max_mem on more than 20%    
-    assert estimated_memory_mb >= max_mem_mb 
+    # the resulting percent value should not deviate from max_mem on more than 20%
+    assert estimated_memory_mb >= max_mem_mb
     assert percents_relative_maxmem <= 20
-    
+
+
 @pytest.mark.cupy
 @pytest.mark.parametrize("slices", [64, 129])
-def test_remove_stripe_ti_memoryhook(slices, ensure_clean_memory):    
+def test_remove_stripe_ti_memoryhook(slices, ensure_clean_memory):
     dim_x = 156
     dim_y = 216
     data = cp.random.random_sample((slices, dim_x, dim_y), dtype=np.float32)
@@ -249,120 +317,255 @@ def test_remove_stripe_ti_memoryhook(slices, ensure_clean_memory):
         data_filtered = remove_stripe_ti(cp.copy(data)).get()
 
     # make sure estimator function is within range (80% min, 100% max)
-    max_mem = hook.max_mem # the amount of memory in bytes needed for the method according to memoryhook   
-    
+    max_mem = (
+        hook.max_mem
+    )  # the amount of memory in bytes needed for the method according to memoryhook
+
     # now we estimate how much of the total memory required for this data
-    (estimated_memory_bytes, subtract_bytes) = _calc_memory_bytes_remove_stripe_ti((dim_x, dim_y), dtype=np.float32())
-    estimated_memory_mb = round(slices*estimated_memory_bytes / (1024**2), 2)
+    (estimated_memory_bytes, subtract_bytes) = _calc_memory_bytes_remove_stripe_ti(
+        (dim_x, dim_y), dtype=np.float32()
+    )
+    estimated_memory_mb = round(slices * estimated_memory_bytes / (1024**2), 2)
     max_mem -= subtract_bytes
     max_mem_mb = round(max_mem / (1024**2), 2)
-    
-    # now we compare both memory estimations 
+
+    # now we compare both memory estimations
     difference_mb = abs(estimated_memory_mb - max_mem_mb)
-    percents_relative_maxmem = round((difference_mb/max_mem_mb)*100)
+    percents_relative_maxmem = round((difference_mb / max_mem_mb) * 100)
     # the estimated_memory_mb should be LARGER or EQUAL to max_mem_mb
-    # the resulting percent value should not deviate from max_mem on more than 20%    
-    assert estimated_memory_mb >= max_mem_mb 
+    # the resulting percent value should not deviate from max_mem on more than 20%
+    assert estimated_memory_mb >= max_mem_mb
     assert percents_relative_maxmem <= 20
-    
+
+
+@pytest.mark.cupy
+@pytest.mark.parametrize("angles", [900, 1800])
+@pytest.mark.parametrize("dim_x_slices", [1, 3, 5])
+@pytest.mark.parametrize("dim_y", [1280, 2560])
+def test_remove_all_stripe_memoryhook(angles, dim_x_slices, dim_y, ensure_clean_memory):
+    data = cp.random.random_sample((angles, dim_x_slices, dim_y), dtype=np.float32)
+    hook = MaxMemoryHook()
+    with hook:
+        data_filtered = remove_all_stripe(cp.copy(data)).get()
+
+    # make sure estimator function is within range (80% min, 100% max)
+    max_mem = (
+        hook.max_mem
+    )  # the amount of memory in bytes needed for the method according to memoryhook
+    max_mem_mb = round(max_mem / (1024**2), 2)  # now in mbs
+
+    # now we estimate how much of the total memory required for this data
+    (estimated_memory_bytes, subtract_bytes) = _calc_memory_bytes_remove_all_stripe(
+        (angles, dim_y), dtype=np.float32()
+    )
+    estimated_memory_mb = round(dim_x_slices * estimated_memory_bytes / (1024**2), 2)
+    max_mem -= subtract_bytes
+    max_mem_mb = round(max_mem / (1024**2), 2)
+
+    # now we compare both memory estimations
+    difference_mb = abs(estimated_memory_mb - max_mem_mb)
+    # the estimated_memory_mb should be LARGER or EQUAL to max_mem_mb
+    assert estimated_memory_mb >= max_mem_mb
+    # this function is too complex to estimate the memory needed exactly,
+    # but it works in slice-by-slice fashion.
+    # We overestimate and ensure that we're always above the memoryhook limit.
+
+
+@pytest.mark.cupy
+@pytest.mark.parametrize("interpolation", ["nearest", "linear"])
+@pytest.mark.parametrize("slices", [1, 3, 10])
+@pytest.mark.parametrize("newshape", [[256, 256], [500, 500], [1000, 1000]])
+def test_data_sampler_memoryhook(slices, newshape, interpolation, ensure_clean_memory):
+    recon_size = 2560
+    data = cp.random.random_sample((recon_size, slices, recon_size), dtype=cp.float32)
+    kwargs = {}
+    kwargs["newshape"] = newshape
+    kwargs["interpolation"] = interpolation
+    kwargs["axis"] = 1
+
+    hook = MaxMemoryHook()
+    with hook:
+        scaled_data = data_resampler(cp.copy(data), **kwargs)
+
+    # make sure estimator function is within range (80% min, 100% max)
+    max_mem = (
+        hook.max_mem
+    )  # the amount of memory in bytes needed for the method according to memoryhook
+
+    # now we estimate how much of the total memory required for this data
+    (estimated_memory_bytes, subtract_bytes) = _calc_memory_bytes_data_resampler(
+        (recon_size, recon_size), dtype=np.float32(), **kwargs
+    )
+    # as this is slice-by-slice implementation we should be adding slices number
+    estimated_memory_mb = slices * round(estimated_memory_bytes / (1024**2), 2)
+    max_mem -= subtract_bytes
+    max_mem_mb = round(max_mem / (1024**2), 2)
+
+    # now we compare both memory estimations
+    difference_mb = abs(estimated_memory_mb - max_mem_mb)
+    percents_relative_maxmem = round((difference_mb / max_mem_mb) * 100)
+    # the estimated_memory_mb should be LARGER or EQUAL to max_mem_mb
+    assert estimated_memory_mb >= max_mem_mb
+    # for this function it is difficult to estimate the memory requirements as it works
+    # slice by slice. Also the memory usage inside interpn/RegularGridInterpolator is
+    # unknown. We should generally overestitmate the memory here.
+
+
 @pytest.mark.cupy
 @pytest.mark.parametrize("slices", [3, 5, 8])
-@pytest.mark.parametrize("recon_size_it", [600, 1200, 2560])
+@pytest.mark.parametrize("recon_size_it", [1200, 2560])
 def test_recon_FBP_memoryhook(slices, recon_size_it, ensure_clean_memory):
-    data = cp.random.random_sample((1801, slices, recon_size_it), dtype=np.float32)
-    kwargs={}
-    kwargs['angles'] = np.linspace(0.0 * np.pi / 180.0, 180.0 * np.pi / 180.0, data.shape[0])
-    kwargs['center'] = 500    
-    kwargs['recon_size'] = recon_size_it
-    kwargs['recon_mask_radius'] = 0.8
-    recon_size = data.shape[2]
+    data = cp.random.random_sample((1801, slices, 2560), dtype=np.float32)
+    kwargs = {}
+    kwargs["angles"] = np.linspace(
+        0.0 * np.pi / 180.0, 180.0 * np.pi / 180.0, data.shape[0]
+    )
+    kwargs["center"] = 500
+    kwargs["recon_size"] = recon_size_it
+    kwargs["recon_mask_radius"] = 0.8
+
     hook = MaxMemoryHook()
     with hook:
-        recon_data = FBP(data, **kwargs)
+        recon_data = FBP(cp.copy(data), **kwargs)
 
     # make sure estimator function is within range (80% min, 100% max)
-    max_mem = hook.max_mem # the amount of memory in bytes needed for the method according to memoryhook   
-    max_mem_mb = round(max_mem / (1024**2), 2)
-    
+    max_mem = (
+        hook.max_mem
+    )  # the amount of memory in bytes needed for the method according to memoryhook
+
     # now we estimate how much of the total memory required for this data
-    (estimated_memory_bytes, subtract_bytes) = _calc_memory_bytes_FBP((1801, recon_size_it), dtype=np.float32(), **kwargs)
-    estimated_memory_mb = round(slices*estimated_memory_bytes / (1024**2), 2)
+    (estimated_memory_bytes, subtract_bytes) = _calc_memory_bytes_FBP(
+        (1801, 2560), dtype=np.float32(), **kwargs
+    )
+    estimated_memory_mb = round(slices * estimated_memory_bytes / (1024**2), 2)
     max_mem -= subtract_bytes
-    max_mem_mb = round(max_mem / (1024**2), 2)   
-    
-    # now we compare both memory estimations 
+    max_mem_mb = round(max_mem / (1024**2), 2)
+
+    # now we compare both memory estimations
     difference_mb = abs(estimated_memory_mb - max_mem_mb)
-    percents_relative_maxmem = round((difference_mb/max_mem_mb)*100)
+    percents_relative_maxmem = round((difference_mb / max_mem_mb) * 100)
     # the estimated_memory_mb should be LARGER or EQUAL to max_mem_mb
-    # the resulting percent value should not deviate from max_mem on more than 20%    
-    assert estimated_memory_mb >= max_mem_mb 
+    # the resulting percent value should not deviate from max_mem on more than 20%
+    assert estimated_memory_mb >= max_mem_mb
     assert percents_relative_maxmem <= 35
-    
-    
+
+
 @pytest.mark.cupy
-@pytest.mark.parametrize("slices", [3, 5, 8])
-def test_recon_SIRT_memoryhook(slices, ensure_clean_memory):
+@pytest.mark.parametrize("slices", [3, 5])
+@pytest.mark.parametrize("recon_size_it", [1200, 2000])
+def test_recon_SIRT_memoryhook(slices, recon_size_it, ensure_clean_memory):
     data = cp.random.random_sample((1801, slices, 2560), dtype=np.float32)
-    recon_size = data.shape[2]
+    kwargs = {}
+    kwargs["recon_size"] = recon_size_it
+
     hook = MaxMemoryHook()
     with hook:
         recon_data = SIRT(
-                    data,
-                    np.linspace(0.0 * np.pi / 180.0, 180.0 * np.pi / 180.0, data.shape[0]),
-                    1200,
-                    recon_size=recon_size,
-                    iterations=2,
-                    nonnegativity=True,
-                )
+            cp.copy(data),
+            np.linspace(0.0 * np.pi / 180.0, 180.0 * np.pi / 180.0, data.shape[0]),
+            1200,
+            recon_size=recon_size_it,
+            iterations=2,
+            nonnegativity=True,
+        )
 
     # make sure estimator function is within range (80% min, 100% max)
-    max_mem = hook.max_mem # the amount of memory in bytes needed for the method according to memoryhook   
-    max_mem_mb = round(max_mem / (1024**2), 2)
-    
+    max_mem = (
+        hook.max_mem
+    )  # the amount of memory in bytes needed for the method according to memoryhook
+
     # now we estimate how much of the total memory required for this data
-    library_info = get_method_info("httomolibgpu.recon.algorithm", "SIRT", "memory_gpu")
-    estimated_memory_bytes = library_info[1]['multipliers'][0] * np.prod(cp.shape(data)) * float32().nbytes
-    estimated_memory_mb = round(estimated_memory_bytes / (1024**2), 2)
-    
-    # now we compare both memory estimations 
+    (estimated_memory_bytes, subtract_bytes) = _calc_memory_bytes_SIRT(
+        (1801, 2560), dtype=np.float32(), **kwargs
+    )
+    estimated_memory_mb = round(slices * estimated_memory_bytes / (1024**2), 2)
+    max_mem -= subtract_bytes
+    max_mem_mb = round(max_mem / (1024**2), 2)
+
+    # now we compare both memory estimations
     difference_mb = abs(estimated_memory_mb - max_mem_mb)
-    percents_relative_maxmem = round((difference_mb/max_mem_mb)*100)
+    percents_relative_maxmem = round((difference_mb / max_mem_mb) * 100)
     # the estimated_memory_mb should be LARGER or EQUAL to max_mem_mb
-    # the resulting percent value should not deviate from max_mem on more than 20%    
-    assert estimated_memory_mb >= max_mem_mb 
-    assert percents_relative_maxmem <= 20
+    # the resulting percent value should not deviate from max_mem on more than 20%
+    assert estimated_memory_mb >= max_mem_mb
+    assert percents_relative_maxmem <= 25
 
 
 @pytest.mark.cupy
 @pytest.mark.parametrize("slices", [3, 5])
-def test_recon_CGLS_memoryhook(slices, ensure_clean_memory):
+@pytest.mark.parametrize("recon_size_it", [1200, 2000])
+def test_recon_CGLS_memoryhook(slices, recon_size_it, ensure_clean_memory):
     data = cp.random.random_sample((1801, slices, 2560), dtype=np.float32)
-    recon_size = data.shape[2]
+    kwargs = {}
+    kwargs["recon_size"] = recon_size_it
+
     hook = MaxMemoryHook()
     with hook:
         recon_data = CGLS(
-                    data,
-                    np.linspace(0.0 * np.pi / 180.0, 180.0 * np.pi / 180.0, data.shape[0]),
-                    1200,
-                    recon_size=recon_size,
-                    iterations=2,
-                    nonnegativity=True,
-                )
+            cp.copy(data),
+            np.linspace(0.0 * np.pi / 180.0, 180.0 * np.pi / 180.0, data.shape[0]),
+            1200,
+            recon_size=recon_size_it,
+            iterations=2,
+            nonnegativity=True,
+        )
 
     # make sure estimator function is within range (80% min, 100% max)
-    max_mem = hook.max_mem # the amount of memory in bytes needed for the method according to memoryhook   
-    max_mem_mb = round(max_mem / (1024**2), 2)
-    
+    max_mem = (
+        hook.max_mem
+    )  # the amount of memory in bytes needed for the method according to memoryhook
+
     # now we estimate how much of the total memory required for this data
-    library_info = get_method_info("httomolibgpu.recon.algorithm", "CGLS", "memory_gpu")
-    estimated_memory_bytes = library_info[1]['multipliers'][0] * np.prod(cp.shape(data)) * float32().nbytes
-    estimated_memory_mb = round(estimated_memory_bytes / (1024**2), 2)
-    
-    # now we compare both memory estimations 
+    (estimated_memory_bytes, subtract_bytes) = _calc_memory_bytes_CGLS(
+        (1801, 2560), dtype=np.float32(), **kwargs
+    )
+    estimated_memory_mb = round(slices * estimated_memory_bytes / (1024**2), 2)
+    max_mem -= subtract_bytes
+    max_mem_mb = round(max_mem / (1024**2), 2)
+
+    # now we compare both memory estimations
     difference_mb = abs(estimated_memory_mb - max_mem_mb)
-    percents_relative_maxmem = round((difference_mb/max_mem_mb)*100)
+    percents_relative_maxmem = round((difference_mb / max_mem_mb) * 100)
     # the estimated_memory_mb should be LARGER or EQUAL to max_mem_mb
-    # the resulting percent value should not deviate from max_mem on more than 20%    
-    assert estimated_memory_mb >= max_mem_mb 
+    # the resulting percent value should not deviate from max_mem on more than 20%
+    assert estimated_memory_mb >= max_mem_mb
     assert percents_relative_maxmem <= 20
-    
\ No newline at end of file
+
+
+@pytest.mark.cupy
+@pytest.mark.parametrize("bits", [8, 16, 32])
+@pytest.mark.parametrize("slices", [3, 5, 8])
+@pytest.mark.parametrize("glob_stats", [False, True])
+def test_rescale_to_int_memoryhook(
+    data, ensure_clean_memory, slices: int, bits: Literal[8, 16, 32], glob_stats: bool
+):
+    data = cp.random.random_sample((1801, slices, 600), dtype=np.float32)
+    kwargs: dict = {}
+    kwargs["bits"] = bits
+    if glob_stats:
+        kwargs["glob_stats"] = (0.0, 10.0, 120.0, data.size)
+    hook = MaxMemoryHook()
+    with hook:
+        rescale_to_int(cp.copy(data), **kwargs).get()
+
+    # make sure estimator function is within range (80% min, 100% max)
+    max_mem = (
+        hook.max_mem
+    )  # the amount of memory in bytes needed for the method according to memoryhook
+    max_mem_mb = round(max_mem / (1024**2), 2)
+
+    # now we estimate how much of the total memory required for this data
+    (estimated_memory_bytes, subtract_bytes) = _calc_memory_bytes_rescale_to_int(
+        (data.shape[0], data.shape[2]), dtype=np.float32(), **kwargs
+    )
+    estimated_memory_mb = round(slices * estimated_memory_bytes / (1024**2), 2)
+    max_mem -= subtract_bytes
+    max_mem_mb = round(max_mem / (1024**2), 2)
+
+    # now we compare both memory estimations
+    difference_mb = abs(estimated_memory_mb - max_mem_mb)
+    percents_relative_maxmem = round((difference_mb / max_mem_mb) * 100)
+    # the estimated_memory_mb should be LARGER or EQUAL to max_mem_mb
+    # the resulting percent value should not deviate from max_mem on more than 20%
+    assert estimated_memory_mb >= max_mem_mb
+    assert percents_relative_maxmem <= 35
diff --git a/tests/test_cli.py b/tests/test_cli.py
index b3b7d2b31..b177c6a30 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -3,8 +3,10 @@
 from pathlib import Path
 
 import pytest
+from pytest_mock import MockerFixture
 
 from httomo import __version__
+from httomo.cli import transform_limit_str_to_bytes
 
 
 def test_cli_version_shows_version():
@@ -65,11 +67,10 @@ def test_cli_pass_output_folder(
 """
 
 @pytest.mark.cupy
-def test_cli_pass_gpu_id(cmd, standard_data, standard_loader):
-    output_dir = "output_dir"  # dir created by the `output_folder` fixture
+def test_cli_pass_gpu_id(cmd, standard_data, standard_loader, output_folder):
     cmd.insert(4, standard_data)
     cmd.insert(5, standard_loader)
-    cmd.insert(6, output_dir)
+    cmd.insert(6, output_folder)
     cmd.insert(7, "--gpu-id")
     cmd.insert(8, "100")
 
@@ -77,3 +78,26 @@ def test_cli_pass_gpu_id(cmd, standard_data, standard_loader):
         cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
     )
     assert "GPU Device not available for access." in result.stderr
+
+
+@pytest.mark.parametrize("cli_parameter,limit_bytes", [
+    ("0", 0),
+    ("500", 500),
+    ("500k", 500 * 1024),
+    ("1M",  1024*1024),
+    ("1m",  1024*1024),
+    ("3g", 3 * 1024**3),
+    ("3.2g", int(3.2 * 1024**3))
+])
+def test_cli_transforms_memory_limits(cli_parameter: str, limit_bytes: int):
+    assert transform_limit_str_to_bytes(cli_parameter) == limit_bytes
+    
+
+@pytest.mark.parametrize("cli_parameter", [
+    "abcd", "nolimit", "124A", "23ki"
+])
+def test_cli_fails_transforming_memory_limits(cli_parameter: str):
+    with pytest.raises(ValueError) as e:
+        transform_limit_str_to_bytes(cli_parameter)
+        
+    assert f"invalid memory limit string {cli_parameter}" in str(e)
\ No newline at end of file
diff --git a/tests/test_get_darks_flats.py b/tests/test_get_darks_flats.py
new file mode 100644
index 000000000..364e02dda
--- /dev/null
+++ b/tests/test_get_darks_flats.py
@@ -0,0 +1,152 @@
+from pathlib import Path
+
+import pytest
+import h5py
+import numpy as np
+
+from httomo.darks_flats import DarksFlatsFileConfig, get_darks_flats
+from httomo.preview import PreviewConfig, PreviewDimConfig
+
+
+@pytest.mark.parametrize(
+    "preview_config",
+    [
+        PreviewConfig(
+            angles=PreviewDimConfig(start=0, stop=180),
+            detector_y=PreviewDimConfig(start=0, stop=128),
+            detector_x=PreviewDimConfig(start=0, stop=160),
+        ),
+        PreviewConfig(
+            angles=PreviewDimConfig(start=0, stop=180),
+            detector_y=PreviewDimConfig(start=0, stop=10),
+            detector_x=PreviewDimConfig(start=0, stop=160),
+        ),
+        PreviewConfig(
+            angles=PreviewDimConfig(start=0, stop=180),
+            detector_y=PreviewDimConfig(start=10, stop=20),
+            detector_x=PreviewDimConfig(start=0, stop=160),
+        ),
+        PreviewConfig(
+            angles=PreviewDimConfig(start=0, stop=180),
+            detector_y=PreviewDimConfig(start=0, stop=128),
+            detector_x=PreviewDimConfig(start=0, stop=10),
+        ),
+        PreviewConfig(
+            angles=PreviewDimConfig(start=0, stop=180),
+            detector_y=PreviewDimConfig(start=0, stop=128),
+            detector_x=PreviewDimConfig(start=10, stop=20),
+        ),
+    ],
+    ids=[
+        "no_cropping",
+        "crop_det_y_start_0",
+        "crop_det_y_start_10",
+        "crop_det_x_start_0",
+        "crop_det_x_start_10",
+    ],
+)
+def test_get_darks_flats_same_file_same_dataset(
+    standard_data_path: str,
+    standard_data_darks_flats_config: DarksFlatsFileConfig,
+    preview_config: PreviewConfig,
+):
+    IN_FILE_PATH = Path(__file__).parent / "test_data/tomo_standard.nxs"
+
+    loaded_darks, loaded_flats = get_darks_flats(
+        standard_data_darks_flats_config,
+        standard_data_darks_flats_config,
+        preview_config,
+    )
+
+    FLATS_START = 180
+    FLATS_END = 199
+    DARKS_START = 200
+    DARKS_END = 219
+    with h5py.File(IN_FILE_PATH, "r") as f:
+        dataset: h5py.Dataset = f[standard_data_path]
+        flats = dataset[
+            FLATS_START : FLATS_END + 1,
+            preview_config.detector_y.start : preview_config.detector_y.stop,
+            preview_config.detector_x.start : preview_config.detector_x.stop,
+        ]
+        darks = dataset[
+            DARKS_START : DARKS_END + 1,
+            preview_config.detector_y.start : preview_config.detector_y.stop,
+            preview_config.detector_x.start : preview_config.detector_x.stop,
+        ]
+
+    np.testing.assert_array_equal(loaded_flats, flats)
+    np.testing.assert_array_equal(loaded_darks, darks)
+
+
+@pytest.mark.parametrize(
+    "preview_config",
+    [
+        PreviewConfig(
+            angles=PreviewDimConfig(start=0, stop=180),
+            detector_y=PreviewDimConfig(start=0, stop=128),
+            detector_x=PreviewDimConfig(start=0, stop=160),
+        ),
+        PreviewConfig(
+            angles=PreviewDimConfig(start=0, stop=180),
+            detector_y=PreviewDimConfig(start=0, stop=10),
+            detector_x=PreviewDimConfig(start=0, stop=160),
+        ),
+        PreviewConfig(
+            angles=PreviewDimConfig(start=0, stop=180),
+            detector_y=PreviewDimConfig(start=10, stop=20),
+            detector_x=PreviewDimConfig(start=0, stop=160),
+        ),
+        PreviewConfig(
+            angles=PreviewDimConfig(start=0, stop=180),
+            detector_y=PreviewDimConfig(start=0, stop=128),
+            detector_x=PreviewDimConfig(start=0, stop=10),
+        ),
+        PreviewConfig(
+            angles=PreviewDimConfig(start=0, stop=180),
+            detector_y=PreviewDimConfig(start=0, stop=128),
+            detector_x=PreviewDimConfig(start=10, stop=20),
+        ),
+    ],
+    ids=[
+        "no_cropping",
+        "crop_det_y_start_0",
+        "crop_det_y_start_10",
+        "crop_det_x_start_0",
+        "crop_det_x_start_10",
+    ],
+)
+def test_get_darks_flats_different_file(preview_config: PreviewConfig):
+    DARKS_CONFIG = DarksFlatsFileConfig(
+        file=Path(__file__).parent / "test_data/i12/separate_flats_darks/dark_field.h5",
+        data_path="/1-NoProcessPlugin-tomo/data",
+        image_key_path=None,
+    )
+    FLATS_CONFIG = DarksFlatsFileConfig(
+        file=Path(__file__).parent / "test_data/i12/separate_flats_darks/flat_field.h5",
+        data_path="/1-NoProcessPlugin-tomo/data",
+        image_key_path=None,
+    )
+
+    loaded_darks, loaded_flats = get_darks_flats(
+        DARKS_CONFIG,
+        FLATS_CONFIG,
+        preview_config,
+    )
+
+    with h5py.File(DARKS_CONFIG.file, "r") as f:
+        darks = f[DARKS_CONFIG.data_path][
+            :,
+            preview_config.detector_y.start : preview_config.detector_y.stop,
+            preview_config.detector_x.start : preview_config.detector_x.stop,
+        ]
+
+    with h5py.File(FLATS_CONFIG.file, "r") as f:
+        flats = f[FLATS_CONFIG.data_path][
+            :,
+            preview_config.detector_y.start : preview_config.detector_y.stop,
+            preview_config.detector_x.start : preview_config.detector_x.stop,
+        ]
+
+    np.testing.assert_array_equal(loaded_flats, flats)
+    np.testing.assert_array_equal(loaded_darks, darks)
diff --git a/tests/test_load.py b/tests/test_load.py
index 1d4a45175..79adc7eb7 100644
--- a/tests/test_load.py
+++ b/tests/test_load.py
@@ -42,7 +42,7 @@ def test_load_data(
     file, dim, path_to_data, expected_shape, expected_sum, expected_mean
 ):
     preview = "0:180, :, :"
-    output = load_data(file, dim, path_to_data, preview, comm=comm)
+    output, _ = load_data(file, dim, path_to_data, preview, comm=comm)
 
     assert output.shape == expected_shape
     assert output.dtype == np.uint16
diff --git a/tests/test_loader.py b/tests/test_loader.py
index 29a1e9592..e48f39a9e 100644
--- a/tests/test_loader.py
+++ b/tests/test_loader.py
@@ -9,6 +9,7 @@
 comm = MPI.COMM_WORLD
 
 
+@pytest.mark.skip("outdated - to be removed after refactoring")
 def test_tomo_standard_testing_pipeline_loaded(
     cmd, standard_data, standard_loader, output_folder, testing_pipeline, merge_yamls
 ):
@@ -30,6 +31,7 @@ def test_tomo_standard_testing_pipeline_loaded(
     assert "Running task 6 (pattern=sinogram): recon..." in result.stderr
     assert "Running task 7 (pattern=sinogram): save_to_images.." in result.stderr    
 
+@pytest.mark.skip("outdated - to be removed after refactoring")
 def test_diad_testing_pipeline_loaded(
     cmd, diad_data, diad_loader, output_folder, testing_pipeline, merge_yamls
 ):
diff --git a/tests/test_method_query.py b/tests/test_method_query.py
index ba99bc52c..e5ec51ab3 100644
--- a/tests/test_method_query.py
+++ b/tests/test_method_query.py
@@ -49,6 +49,20 @@ def test_httomolibgpu_output_dims_change():
     assert output_dims_change == False
 
 
+def test_httomolibgpu_default_save_result():
+    save_result = get_method_info(
+        "httomolibgpu.prep.normalize", "normalize", "save_result_default"
+    )
+    
+    assert save_result is False
+    
+def test_httomolibgpu_default_save_result_recon():
+    save_result = get_method_info(
+        "httomolibgpu.recon.algorithm", "FBP", "save_result_default"
+    )
+    
+    assert save_result is True
+
 def test_httomolibgpu_memory_gpu():
     memory_gpu = get_method_info(
         "httomolibgpu.prep.normalize", "normalize", "memory_gpu"
@@ -62,11 +76,11 @@ def test_database_query_object():
     assert query.get_output_dims_change() is False
     assert query.get_implementation() == "gpu_cupy"
     assert query.swap_dims_on_output() is False
+    assert query.save_result_default() is False
     mempars = query.get_memory_gpu_params()
-    assert len(mempars) == 3
-    assert set(p.dataset for p in mempars) == set(["tomo", "flats", "darks"])
-    assert all(p.method == "direct" for p in mempars)
-    assert all(p.multiplier >= 1.0 for p in mempars)
+    assert set(p.dataset for p in mempars) == set(["tomo"])
+    assert all(p.method == "module" for p in mempars)
+    assert all(p.multiplier == 'None' for p in mempars)
 
 
 def test_database_query_object_recon_swap_output():
diff --git a/tests/test_methods.py b/tests/test_methods.py
new file mode 100644
index 000000000..16f97347b
--- /dev/null
+++ b/tests/test_methods.py
@@ -0,0 +1,200 @@
+from pathlib import Path
+import time
+from httomo.methods import calculate_stats, save_intermediate_data
+
+import numpy as np
+import pytest
+from mpi4py import MPI
+import h5py
+from httomo.runner.auxiliary_data import AuxiliaryData
+from httomo.runner.dataset import DataSetBlock
+
+from httomo.utils import gpu_enabled, xp
+
+
+def test_calculate_stats_simple():
+    data = np.arange(30, dtype=np.float32).reshape((2, 3, 5)) - 10.0
+    ret = calculate_stats(data)
+
+    assert ret == (-10.0, 19.0, np.sum(data), 30)
+
+
+def test_calculate_stats_with_nan_and_inf():
+    data = np.arange(30, dtype=np.float32).reshape((2, 3, 5)) - 10.0
+    expected = np.sum(data) - data[1, 1, 1] - data[0, 2, 3] - data[1, 2, 3]
+    data[1, 1, 1] = float("inf")
+    data[0, 2, 3] = float("-inf")
+    data[1, 2, 3] = float("nan")
+    ret = calculate_stats(data)
+
+    assert ret == (-10.0, 19.0, expected, 30)
+
+
+@pytest.mark.skipif(
+    not gpu_enabled or xp.cuda.runtime.getDeviceCount() == 0,
+    reason="skipped as cupy is not available",
+)
+@pytest.mark.cupy
+def test_calculate_stats_gpu():
+    data = xp.arange(30, dtype=np.float32).reshape((2, 3, 5)) - 10.0
+    ret = calculate_stats(data)
+
+    assert ret == (-10.0, 19.0, np.sum(data.get()), 30)
+    
+
+@pytest.mark.perf
+@pytest.mark.parametrize("gpu", [False, True], ids=["CPU", "GPU"])
+def test_calculcate_stats_performance(gpu: bool):
+    if gpu and not gpu_enabled:
+        pytest.skip("No GPU available")
+        
+    data = np.random.randint(
+        low=7515, high=37624, size=(1801, 5, 2560), dtype=np.uint32
+    ).astype(np.float32)
+    
+    if gpu:
+        data = xp.asarray(data)
+        xp.cuda.Device().synchronize()
+    start = time.perf_counter_ns()
+    for _ in range(10):
+        calculate_stats(data)
+    if gpu:
+        xp.cuda.Device().synchronize()
+    stop = time.perf_counter_ns()
+    duration_ms = float(stop-start) * 1e-6 / 10
+    
+    # Note: on Quadro RTX 6000 vs Xeon(R) Gold 6148, GPU is about 10x faster 
+    assert "performance in ms" == duration_ms
+    
+    
+def test_save_intermediate_data(tmp_path: Path):
+    # use increasing numbers in the data, to make sure blocks have different content
+    GLOBAL_SHAPE=(10,10,10)
+    global_data = np.arange(np.prod(GLOBAL_SHAPE), dtype=np.float32).reshape(
+        GLOBAL_SHAPE
+    )
+    aux_data = AuxiliaryData(angles=np.ones(GLOBAL_SHAPE[0], dtype=np.float32))
+    bsize = 3
+    b1 = DataSetBlock(data=global_data[:bsize], aux_data=aux_data,
+                      slicing_dim=0,
+                      block_start=0,
+                      chunk_start=0,
+                      chunk_shape=GLOBAL_SHAPE,
+                      global_shape=GLOBAL_SHAPE)
+    b2 = DataSetBlock(data=global_data[bsize:], aux_data=aux_data,
+                      slicing_dim=0,
+                      block_start=bsize,
+                      chunk_start=0,
+                      chunk_shape=GLOBAL_SHAPE,
+                      global_shape=GLOBAL_SHAPE)
+
+    with h5py.File(
+        tmp_path / "test_file.h5", "w", driver="mpio", comm=MPI.COMM_WORLD
+    ) as file:
+        # save in 2 blocks, starting with the second to confirm order-independence
+        save_intermediate_data(
+            b2.data,
+            b2.global_shape,
+            b2.global_index,
+            file,
+            path="/data",
+            detector_x=10,
+            detector_y=20,
+            angles=b2.angles,
+        )
+        save_intermediate_data(
+            b1.data,
+            b1.global_shape,
+            b1.global_index,
+            file,
+            path="/data",
+            detector_x=10,
+            detector_y=20,
+            angles=b1.angles,
+        )
+
+    with h5py.File(tmp_path / "test_file.h5", "r") as file:
+        assert "/data" in file
+        data = file["/data"]
+        np.testing.assert_array_equal(data, global_data)
+        assert "/angles" in file
+        angles = file["/angles"]
+        np.testing.assert_array_equal(angles, aux_data.get_angles())
+        assert "test_file.h5" in file
+        np.testing.assert_array_equal(file["test_file.h5"], [0, 0])
+        assert "data_dims" in file
+        np.testing.assert_array_equal(file["data_dims"]["detector_x_y"], [10, 20])
+
+
+@pytest.mark.mpi
+@pytest.mark.skipif(
+    MPI.COMM_WORLD.size != 2, reason="Only rank-2 MPI is supported with this test"
+)
+def test_save_intermediate_data_mpi(tmp_path: Path):
+    comm = MPI.COMM_WORLD
+    # make sure we use the same tmp_path on both processes
+    tmp_path = comm.bcast(tmp_path)
+    GLOBAL_SHAPE = (10, 10, 10)
+    csize=5
+    # use increasing numbers in the data, to make sure blocks have different content
+    global_data = np.arange(np.prod(GLOBAL_SHAPE), dtype=np.float32).reshape(
+        GLOBAL_SHAPE
+    )
+    aux_data = AuxiliaryData(angles=np.ones(GLOBAL_SHAPE[0], dtype=np.float32))
+    # give each process only a portion of the data
+    rank_data = global_data[:csize, :, :] if comm.rank == 0 else global_data[csize:, :, :]
+    # create 2 blocks per rank
+    b1 = DataSetBlock(
+        data=rank_data[:3, :, :],
+        aux_data=aux_data,
+        slicing_dim=0,
+        block_start=0,
+        chunk_start=0 if comm.rank == 0 else csize,
+        global_shape=GLOBAL_SHAPE,
+        chunk_shape=(csize, GLOBAL_SHAPE[1], GLOBAL_SHAPE[2])
+    )
+    b2 = DataSetBlock(
+        data=rank_data[3:, :, :],
+        aux_data=aux_data,
+        slicing_dim=0,
+        block_start=3,
+        chunk_start=0 if comm.rank == 0 else csize,
+        global_shape=GLOBAL_SHAPE,
+        chunk_shape=(csize, GLOBAL_SHAPE[1], GLOBAL_SHAPE[2])
+    )
+
+    with h5py.File(tmp_path / "test_file.h5", "w", driver="mpio", comm=comm) as file:
+        # save in 2 blocks, starting with the second to confirm order-independence
+        save_intermediate_data(
+            b2.data,
+            b2.global_shape,
+            b2.global_index,
+            file,
+            path="/data",
+            detector_x=10,
+            detector_y=20,
+            angles=b2.angles,
+        )
+        save_intermediate_data(
+            b1.data,
+            b1.global_shape,
+            b1.global_index,
+            file,
+            path="/data",
+            detector_x=10,
+            detector_y=20,
+            angles=b1.angles,
+        )
+
+    # open without MPI, looking at the full file
+    with h5py.File(tmp_path / "test_file.h5", "r") as file:
+        assert "/data" in file
+        data = file["/data"]
+        np.testing.assert_array_equal(data, global_data)
+        assert "/angles" in file
+        angles = file["/angles"]
+        np.testing.assert_array_equal(angles, aux_data.get_angles())
+        assert "test_file.h5" in file
+        np.testing.assert_array_equal(file["test_file.h5"], [0, 0])
+        assert "data_dims" in file
+        np.testing.assert_array_equal(file["data_dims"]["detector_x_y"], [10, 20])
diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py
index ac6cd1d7a..61581a568 100644
--- a/tests/test_pipeline.py
+++ b/tests/test_pipeline.py
@@ -75,8 +75,8 @@ def test_tomo_standard_testing_pipeline_output(
             with h5py.File(file_to_open, "r") as f:
                 assert f["data"].shape == (160, 3, 160)
                 assert f["data"].dtype == np.float32
-                assert_allclose(np.mean(f["data"]), 0.0015362317, atol=1e-6)
-                assert_allclose(np.sum(f["data"]), 117.9826, atol=1e-6)
+                assert_allclose(np.mean(f["data"]), 0.0015362317, atol=1e-6, rtol=1e-6)
+                assert_allclose(np.sum(f["data"]), 117.9826, atol=1e-6, rtol=1e-6)
 
     #: some basic testing of the generated user.log file, because running the whole pipeline again
     #: will slow down the execution of the test suite.
@@ -91,10 +91,8 @@ def test_tomo_standard_testing_pipeline_output(
     assert "The full dataset shape is (220, 128, 160)" in log_contents
     assert "Loading data: tests/test_data/tomo_standard.nxs" in log_contents
     assert "Path to data: entry1/tomo_entry/data/data" in log_contents
-    assert "Preview: (0:180, 57:60:, :)" in log_contents
+    assert "Preview: (0:180, 57:60, 0:160)" in log_contents
     assert "Data shape is (180, 3, 160) of type uint16" in log_contents
-    assert "<-------Reslicing/rechunking the data-------->" in log_contents
-    assert "Reslicing not necessary, as there is only one process" in log_contents
 
 
 def test_run_pipeline_cpu1_yaml(cmd, standard_data, yaml_cpu_pipeline1, output_folder):
@@ -106,7 +104,7 @@ def test_run_pipeline_cpu1_yaml(cmd, standard_data, yaml_cpu_pipeline1, output_f
 
     # recurse through output_dir and check that all files are there
     files = read_folder("output_dir/")
-    assert len(files) == 130
+    assert len(files) == 131  # 128 images + yaml, log, intermdiate
 
     # check the .tif files
     tif_files = list(filter(lambda x: ".tif" in x, files))
@@ -124,10 +122,9 @@ def test_run_pipeline_cpu1_yaml(cmd, standard_data, yaml_cpu_pipeline1, output_f
     assert "The full dataset shape is (220, 128, 160)" in log_contents
     assert "Loading data: tests/test_data/tomo_standard.nxs" in log_contents
     assert "Path to data: entry1/tomo_entry/data/data" in log_contents
-    assert "Preview: (0:180, :, :)" in log_contents
+    assert "The center of rotation is 79.5" in log_contents
+    assert "Preview: (0:180, 0:128, 0:160)" in log_contents
     assert "Data shape is (180, 128, 160) of type uint16" in log_contents
-    assert "<-------Reslicing/rechunking the data-------->" in log_contents
-    assert "Reslicing not necessary, as there is only one process" in log_contents
 
 
 def test_run_pipeline_cpu1_py(cmd, standard_data, python_cpu_pipeline1, output_folder):
@@ -139,7 +136,7 @@ def test_run_pipeline_cpu1_py(cmd, standard_data, python_cpu_pipeline1, output_f
 
     # recurse through output_dir and check that all files are there
     files = read_folder("output_dir/")
-    assert len(files) == 130
+    assert len(files) == 131  # 128 images + yaml, log, intermdiate
 
     # check the .tif files
     tif_files = list(filter(lambda x: ".tif" in x, files))
@@ -157,10 +154,8 @@ def test_run_pipeline_cpu1_py(cmd, standard_data, python_cpu_pipeline1, output_f
     assert "The full dataset shape is (220, 128, 160)" in log_contents
     assert "Loading data: tests/test_data/tomo_standard.nxs" in log_contents
     assert "Path to data: entry1/tomo_entry/data/data" in log_contents
-    assert "Preview: (0:180, :, :)" in log_contents
+    assert "Preview: (0:180, 0:128, 0:160)" in log_contents
     assert "Data shape is (180, 128, 160) of type uint16" in log_contents
-    assert "<-------Reslicing/rechunking the data-------->" in log_contents
-    assert "Reslicing not necessary, as there is only one process" in log_contents
 
 
 def test_run_pipeline_cpu2_yaml(cmd, standard_data, yaml_cpu_pipeline2, output_folder):
@@ -193,7 +188,7 @@ def test_run_pipeline_cpu2_yaml(cmd, standard_data, yaml_cpu_pipeline2, output_f
             with h5py.File(file_to_open, "r") as f:
                 assert f["data"].shape == (160, 30, 160)
                 assert f["data"].dtype == np.float32
-                assert_allclose(np.sum(f["data"]), 694.70306, atol=1e-6)
+                assert_allclose(np.sum(f["data"]), 694.70306, atol=1e-6, rtol=1e-6)
 
     log_contents = _get_log_contents(log_files[0])
 
@@ -201,12 +196,8 @@ def test_run_pipeline_cpu2_yaml(cmd, standard_data, yaml_cpu_pipeline2, output_f
     assert "The full dataset shape is (220, 128, 160)" in log_contents
     assert "Loading data: tests/test_data/tomo_standard.nxs" in log_contents
     assert "Path to data: entry1/tomo_entry/data/data" in log_contents
-    assert "Preview: (0:180, 30:60:, :)" in log_contents
+    assert "Preview: (0:180, 30:60, 0:160)" in log_contents
     assert "Data shape is (180, 30, 160) of type uint16" in log_contents
-    assert "<-------Reslicing/rechunking the data-------->" in log_contents
-    assert "Reslicing not necessary, as there is only one process" in log_contents
-    assert "Maximum amount of slices is 30 for section 1" in log_contents
-    assert "Maximum amount of slices is 30 for section 2" in log_contents
 
 
 def test_run_pipeline_cpu2_py(cmd, standard_data, python_cpu_pipeline2, output_folder):
@@ -236,7 +227,7 @@ def test_run_pipeline_cpu2_py(cmd, standard_data, python_cpu_pipeline2, output_f
             with h5py.File(file_to_open, "r") as f:
                 assert f["data"].shape == (160, 30, 160)
                 assert f["data"].dtype == np.float32
-                assert_allclose(np.sum(f["data"]), 694.70306, atol=1e-6)
+                assert_allclose(np.sum(f["data"]), 694.70306, atol=1e-6, rtol=1e-6)
 
     log_files = list(filter(lambda x: ".log" in x, files))
     assert len(log_files) == 1
@@ -247,12 +238,8 @@ def test_run_pipeline_cpu2_py(cmd, standard_data, python_cpu_pipeline2, output_f
     assert "The full dataset shape is (220, 128, 160)" in log_contents
     assert "Loading data: tests/test_data/tomo_standard.nxs" in log_contents
     assert "Path to data: entry1/tomo_entry/data/data" in log_contents
-    assert "Preview: (0:180, 30:60:, :)" in log_contents
+    assert "Preview: (0:180, 30:60, 0:160)" in log_contents
     assert "Data shape is (180, 30, 160) of type uint16" in log_contents
-    assert "<-------Reslicing/rechunking the data-------->" in log_contents
-    assert "Reslicing not necessary, as there is only one process" in log_contents
-    assert "Maximum amount of slices is 30 for section 1" in log_contents
-    assert "Maximum amount of slices is 30 for section 2" in log_contents
 
 
 def test_run_pipeline_cpu3_yaml(cmd, standard_data, yaml_cpu_pipeline3, output_folder):
@@ -264,7 +251,7 @@ def test_run_pipeline_cpu3_yaml(cmd, standard_data, yaml_cpu_pipeline3, output_f
 
     # recurse through output_dir and check that all files are there
     files = read_folder("output_dir/")
-    assert len(files) == 130
+    assert len(files) == 131  # 128 images + yaml, log, intermdiate
 
     # check the .tif files
     tif_files = list(filter(lambda x: ".tif" in x, files))
@@ -275,7 +262,7 @@ def test_run_pipeline_cpu3_yaml(cmd, standard_data, yaml_cpu_pipeline3, output_f
 
     #: check the generated h5 files
     h5_files = list(filter(lambda x: ".h5" in x, files))
-    assert len(h5_files) == 0
+    assert len(h5_files) == 1
 
     log_files = list(filter(lambda x: ".log" in x, files))
     assert len(log_files) == 1
@@ -285,13 +272,11 @@ def test_run_pipeline_cpu3_yaml(cmd, standard_data, yaml_cpu_pipeline3, output_f
     assert "The full dataset shape is (220, 128, 160)" in log_contents
     assert "Loading data: tests/test_data/tomo_standard.nxs" in log_contents
     assert "Path to data: entry1/tomo_entry/data/data" in log_contents
-    assert "Preview: (0:180, :, :)" in log_contents
+    assert "Preview: (0:180, 0:128, 0:160)" in log_contents
     assert "Data shape is (180, 128, 160) of type uint16" in log_contents
-    assert "<-------Reslicing/rechunking the data-------->" in log_contents
-    assert "Reslicing not necessary, as there is only one process" in log_contents
-    assert " Global min -0.01500" in log_contents
-    assert " Global max 0.041933" in log_contents
-    assert " Global mean 0.001633" in log_contents
+    assert " Global min -0.014979" in log_contents
+    assert " Global max 0.04177" in log_contents
+    assert " Global mean 0.0016174" in log_contents
 
 
 def test_run_pipeline_cpu3_py(cmd, standard_data, python_cpu_pipeline3, output_folder):
@@ -303,7 +288,7 @@ def test_run_pipeline_cpu3_py(cmd, standard_data, python_cpu_pipeline3, output_f
 
     # recurse through output_dir and check that all files are there
     files = read_folder("output_dir/")
-    assert len(files) == 130
+    assert len(files) == 131  # 128 images + yaml, log, intermdiate
 
     # check the .tif files
     tif_files = list(filter(lambda x: ".tif" in x, files))
@@ -314,7 +299,7 @@ def test_run_pipeline_cpu3_py(cmd, standard_data, python_cpu_pipeline3, output_f
 
     #: check the generated h5 files
     h5_files = list(filter(lambda x: ".h5" in x, files))
-    assert len(h5_files) == 0
+    assert len(h5_files) == 1
 
     log_files = list(filter(lambda x: ".log" in x, files))
     assert len(log_files) == 1
@@ -324,13 +309,46 @@ def test_run_pipeline_cpu3_py(cmd, standard_data, python_cpu_pipeline3, output_f
     assert "The full dataset shape is (220, 128, 160)" in log_contents
     assert "Loading data: tests/test_data/tomo_standard.nxs" in log_contents
     assert "Path to data: entry1/tomo_entry/data/data" in log_contents
-    assert "Preview: (0:180, :, :)" in log_contents
+    assert "Preview: (0:180, 0:128, 0:160)" in log_contents
+    assert "Data shape is (180, 128, 160) of type uint16" in log_contents
+    assert " Global min -0.014979" in log_contents
+    assert " Global max 0.04177" in log_contents
+    assert " Global mean 0.0016174" in log_contents
+
+
+def test_run_pipeline_cpu4_yaml(cmd, standard_data, yaml_cpu_pipeline4, output_folder):
+    cmd.pop(4)  #: don't save all
+    cmd.insert(6, standard_data)
+    cmd.insert(7, yaml_cpu_pipeline4)
+    cmd.insert(8, output_folder)
+    subprocess.check_output(cmd)
+
+    # recurse through output_dir and check that all files are there
+    files = read_folder("output_dir/")
+    assert len(files) == 131  # 128 images + yaml, log, intermdiate
+
+    # check the .tif files
+    tif_files = list(filter(lambda x: ".tif" in x, files))
+    assert len(tif_files) == 128
+    #: check that the image size is correct
+    imarray = np.array(Image.open(tif_files[0]))
+    assert imarray.shape == (160, 160)
+
+    #: check the generated h5 files
+    h5_files = list(filter(lambda x: ".h5" in x, files))
+    assert len(h5_files) == 1
+
+    log_files = list(filter(lambda x: ".log" in x, files))
+    assert len(log_files) == 1
+    log_contents = _get_log_contents(log_files[0])
+
+    assert f"{log_files[0]}" in log_contents
+    assert "The full dataset shape is (220, 128, 160)" in log_contents
+    assert "Loading data: tests/test_data/tomo_standard.nxs" in log_contents
+    assert "Path to data: entry1/tomo_entry/data/data" in log_contents
+    assert "The center of rotation is 79.5" in log_contents
+    assert "Preview: (0:180, 0:128, 0:160)" in log_contents
     assert "Data shape is (180, 128, 160) of type uint16" in log_contents
-    assert "<-------Reslicing/rechunking the data-------->" in log_contents
-    assert "Reslicing not necessary, as there is only one process" in log_contents
-    assert " Global min -0.01500" in log_contents
-    assert " Global max 0.041933" in log_contents
-    assert " Global mean 0.001633" in log_contents
 
 
 def test_run_pipeline_gpu1_yaml(cmd, standard_data, yaml_gpu_pipeline1, output_folder):
@@ -360,7 +378,7 @@ def test_run_pipeline_gpu1_yaml(cmd, standard_data, yaml_gpu_pipeline1, output_f
             with h5py.File(file_to_open, "r") as f:
                 assert f["data"].shape == (160, 128, 160)
                 assert f["data"].dtype == np.float32
-                assert_allclose(np.sum(f["data"]), 2615.7332, atol=1e-6)
+                assert_allclose(np.sum(f["data"]), 2615.7332, atol=1e-6, rtol=1e-6)
 
     log_files = list(filter(lambda x: ".log" in x, files))
     assert len(log_files) == 1
@@ -371,10 +389,8 @@ def test_run_pipeline_gpu1_yaml(cmd, standard_data, yaml_gpu_pipeline1, output_f
     assert "The full dataset shape is (220, 128, 160)" in log_contents
     assert "Loading data: tests/test_data/tomo_standard.nxs" in log_contents
     assert "Path to data: entry1/tomo_entry/data/data" in log_contents
-    assert "Preview: (0:180, :, :)" in log_contents
+    assert "Preview: (0:180, 0:128, 0:160)" in log_contents
     assert "Data shape is (180, 128, 160) of type uint16" in log_contents
-    assert "<-------Reslicing/rechunking the data-------->" in log_contents
-    assert "Reslicing not necessary, as there is only one process" in log_contents
     assert "The amount of the available GPU memory is" in log_contents
     assert "Using GPU 0 to transfer data of shape (128, 160)" in log_contents
 
@@ -406,7 +422,7 @@ def test_run_pipeline_gpu1_py(cmd, standard_data, python_gpu_pipeline1, output_f
             with h5py.File(file_to_open, "r") as f:
                 assert f["data"].shape == (160, 128, 160)
                 assert f["data"].dtype == np.float32
-                assert_allclose(np.sum(f["data"]), 2615.7332, atol=1e-6)
+                assert_allclose(np.sum(f["data"]), 2615.7332, atol=1e-6, rtol=1e-6)
 
     log_files = list(filter(lambda x: ".log" in x, files))
     assert len(log_files) == 1
@@ -417,10 +433,8 @@ def test_run_pipeline_gpu1_py(cmd, standard_data, python_gpu_pipeline1, output_f
     assert "The full dataset shape is (220, 128, 160)" in log_contents
     assert "Loading data: tests/test_data/tomo_standard.nxs" in log_contents
     assert "Path to data: entry1/tomo_entry/data/data" in log_contents
-    assert "Preview: (0:180, :, :)" in log_contents
+    assert "Preview: (0:180, 0:128, 0:160)" in log_contents
     assert "Data shape is (180, 128, 160) of type uint16" in log_contents
-    assert "<-------Reslicing/rechunking the data-------->" in log_contents
-    assert "Reslicing not necessary, as there is only one process" in log_contents
     assert "The amount of the available GPU memory is" in log_contents
     assert "Using GPU 0 to transfer data of shape (128, 160)" in log_contents
 
@@ -455,8 +469,8 @@ def test_tomo_standard_testing_pipeline_output_with_save_all(
             with h5py.File(file_to_open, "r") as f:
                 assert f["data"].shape == (160, 3, 160)
                 assert f["data"].dtype == np.float32
-                assert_allclose(np.mean(f["data"]), 0.0015362317, atol=1e-6)
-                assert_allclose(np.sum(f["data"]), 117.9826, atol=1e-6)
+                assert_allclose(np.mean(f["data"]), 0.0015362317, atol=1e-6, rtol=1e-6)
+                assert_allclose(np.sum(f["data"]), 117.9826, atol=1e-6, rtol=1e-6)
 
 
 def test_i12_testing_pipeline_output(
@@ -483,27 +497,27 @@ def test_i12_testing_pipeline_output(
     h5_files = list(filter(lambda x: ".h5" in x, files))
     assert len(h5_files) == 4
 
-    gridrec_recon = list(filter(lambda x: "recon-tomo-gridrec.h5" in x, h5_files))[0]
-    minus_log_tomo = list(filter(lambda x: "minus_log-tomo.h5" in x, h5_files))[0]
+    gridrec_recon = list(filter(lambda x: "recon-gridrec.h5" in x, h5_files))[0]
+    minus_log_tomo = list(filter(lambda x: "minus_log.h5" in x, h5_files))[0]
     remove_stripe_fw_tomo = list(
-        filter(lambda x: "remove_stripe_fw-tomo.h5" in x, h5_files)
+        filter(lambda x: "remove_stripe_fw.h5" in x, h5_files)
     )[0]
-    normalize_tomo = list(filter(lambda x: "normalize-tomo.h5" in x, h5_files))[0]
+    normalize_tomo = list(filter(lambda x: "normalize.h5" in x, h5_files))[0]
 
     with h5py.File(gridrec_recon, "r") as f:
         assert f["data"].shape == (192, 10, 192)
-        assert_allclose(np.sum(f["data"]), 2157.03, atol=1e-2)
-        assert_allclose(np.mean(f["data"]), 0.0058513316, atol=1e-6)
+        assert_allclose(np.sum(f["data"]), 2157.03, atol=1e-2, rtol=1e-6)
+        assert_allclose(np.mean(f["data"]), 0.0058513316, atol=1e-6, rtol=1e-6)
     with h5py.File(minus_log_tomo, "r") as f:
-        assert_allclose(np.sum(f["data"]), 1756628.4, atol=1e-6)
-        assert_allclose(np.mean(f["data"]), 1.2636887, atol=1e-6)
+        assert_allclose(np.sum(f["data"]), 1756628.4, atol=1e-6, rtol=1e-6)
+        assert_allclose(np.mean(f["data"]), 1.2636887, atol=1e-6, rtol=1e-6)
     with h5py.File(remove_stripe_fw_tomo, "r") as f:
-        assert_allclose(np.sum(f["data"]), 1766357.8, atol=1e-6)
-        assert_allclose(np.mean(f["data"]), 1.2706878, atol=1e-6)
+        assert_allclose(np.sum(f["data"]), 1766357.8, atol=1e-6, rtol=1e-6)
+        assert_allclose(np.mean(f["data"]), 1.2706878, atol=1e-6, rtol=1e-6)
     with h5py.File(normalize_tomo, "r") as f:
         assert f["data"].shape == (724, 10, 192)
-        assert_allclose(np.sum(f["data"]), 393510.72, atol=1e-6)
-        assert_allclose(np.mean(f["data"]), 0.28308493, atol=1e-6)
+        assert_allclose(np.sum(f["data"]), 393510.72, atol=1e-6, rtol=1e-6)
+        assert_allclose(np.mean(f["data"]), 0.28308493, atol=1e-6, rtol=1e-6)
 
     log_contents = _get_log_contents(log_files[0])
     assert "The full dataset shape is (724, 10, 192)" in log_contents
@@ -512,58 +526,70 @@ def test_i12_testing_pipeline_output(
         in log_contents
     )
     assert "Path to data: /1-TempPlugin-tomo/data" in log_contents
-    assert "Preview: (0:724, :, :)" in log_contents
-    assert "Saving intermediate file: 2-tomopy-normalize-tomo.h5" in log_contents
-    assert "Saving intermediate file: 3-tomopy-minus_log-tomo.h5" in log_contents
-    assert "Reslicing not necessary, as there is only one process" in log_contents
-    assert "Saving intermediate file: 5-tomopy-remove_stripe_fw-tomo.h5" in log_contents
-    assert "The center of rotation for sinogram is 95.5" in log_contents
-    assert "Saving intermediate file: 6-tomopy-recon-tomo-gridrec.h5" in log_contents
-
-
-def test_i12_testing_ignore_darks_flats_pipeline_output(
-    cmd,
-    i12_data,
-    i12_loader_ignore_darks_flats,
-    testing_pipeline,
-    output_folder,
-    merge_yamls,
-):
-    cmd.insert(7, i12_data)
-    merge_yamls(i12_loader_ignore_darks_flats, testing_pipeline)
-    cmd.insert(8, "temp.yaml")
-    cmd.insert(9, output_folder)
-    subprocess.check_output(cmd)
-
-    files = read_folder("output_dir/")
-    assert len(files) == 16
-
-    copied_yaml_path = list(filter(lambda x: ".yaml" in x, files)).pop()
-    assert compare_two_yamls("temp.yaml", copied_yaml_path)
-
-    log_files = list(filter(lambda x: ".log" in x, files))
-    assert len(log_files) == 1
-
-    tif_files = list(filter(lambda x: ".tif" in x, files))
-    assert len(tif_files) == 10
-
-    h5_files = list(filter(lambda x: ".h5" in x, files))
-    assert len(h5_files) == 4
-
-    log_contents = _get_log_contents(log_files[0])
-    assert "The full dataset shape is (724, 10, 192)" in log_contents
+    assert "Preview: (0:724, 0:10, 0:192)" in log_contents
     assert (
-        "Loading data: tests/test_data/i12/separate_flats_darks/i12_dynamic_start_stop180.nxs"
+        "Running save_task_1 (pattern=projection): save_intermediate_data..."
         in log_contents
     )
-    assert "Path to data: /1-TempPlugin-tomo/data" in log_contents
-    assert "Preview: (0:724, :, :)" in log_contents
-    assert "Saving intermediate file: 2-tomopy-normalize-tomo.h5" in log_contents
-    assert "Saving intermediate file: 3-tomopy-minus_log-tomo.h5" in log_contents
-    assert "Reslicing not necessary, as there is only one process" in log_contents
-    assert "Saving intermediate file: 5-tomopy-remove_stripe_fw-tomo.h5" in log_contents
-    assert "The center of rotation for sinogram is 95.5" in log_contents
-    assert "Saving intermediate file: 6-tomopy-recon-tomo-gridrec.h5" in log_contents
+    assert (
+        "Running save_task_2 (pattern=projection): save_intermediate_data..."
+        in log_contents
+    )
+    assert (
+        "Running save_task_4 (pattern=sinogram): save_intermediate_data..."
+        in log_contents
+    )
+    assert "The center of rotation is 95.5" in log_contents
+    assert (
+        "Running save_task_5 (pattern=sinogram): save_intermediate_data..."
+        in log_contents
+    )
+
+
+# TODO: Add back in when ignoring darks/flats is added to the new loader
+#
+# def test_i12_testing_ignore_darks_flats_pipeline_output(
+#     cmd,
+#     i12_data,
+#     i12_loader_ignore_darks_flats,
+#     testing_pipeline,
+#     output_folder,
+#     merge_yamls,
+# ):
+#     cmd.insert(7, i12_data)
+#     merge_yamls(i12_loader_ignore_darks_flats, testing_pipeline)
+#     cmd.insert(8, "temp.yaml")
+#     cmd.insert(9, output_folder)
+#     subprocess.check_output(cmd)
+#
+#     files = read_folder("output_dir/")
+#     assert len(files) == 16
+#
+#     copied_yaml_path = list(filter(lambda x: ".yaml" in x, files)).pop()
+#     assert compare_two_yamls("temp.yaml", copied_yaml_path)
+#
+#     log_files = list(filter(lambda x: ".log" in x, files))
+#     assert len(log_files) == 1
+#
+#     tif_files = list(filter(lambda x: ".tif" in x, files))
+#     assert len(tif_files) == 10
+#
+#     h5_files = list(filter(lambda x: ".h5" in x, files))
+#     assert len(h5_files) == 4
+#
+#     log_contents = _get_log_contents(log_files[0])
+#     assert "The full dataset shape is (724, 10, 192)" in log_contents
+#     assert (
+#         "Loading data: tests/test_data/i12/separate_flats_darks/i12_dynamic_start_stop180.nxs"
+#         in log_contents
+#     )
+#     assert "Path to data: /1-TempPlugin-tomo/data" in log_contents
+#     assert "Preview: (0:724, 0:10, 0:192)" in log_contents
+#     assert "Running save_task_1 (pattern=projection): save_intermediate_data..." in log_contents
+#     assert "Running save_task_2 (pattern=projection): save_intermediate_data..." in log_contents
+#     assert "Running save_task_4 (pattern=sinogram): save_intermediate_data..." in log_contents
+#     assert "The center of rotation for sinogram is 95.5" in log_contents
+#     assert "Running save_task_5 (pattern=sinogram): save_intermediate_data..." in log_contents
 
 
 def test_diad_testing_pipeline_output(
@@ -600,13 +626,13 @@ def test_diad_testing_pipeline_output(
             with h5py.File(file_to_open, "r") as f:
                 assert f["data"].shape == (3001, 2, 26)
                 assert f["data"].dtype == np.float32
-                assert_allclose(np.mean(f["data"]), 0.847944, atol=1e-6)
-                assert_allclose(np.sum(f["data"]), 132323.36, atol=1e-6)
+                assert_allclose(np.mean(f["data"]), 0.847944, atol=1e-6, rtol=1e-6)
+                assert_allclose(np.sum(f["data"]), 132323.36, atol=1e-6, rtol=1e-6)
         if "tomopy-recon-tomo-gridrec.h5" in file_to_open:
             with h5py.File(file_to_open, "r") as f:
                 assert f["data"].shape == (26, 2, 26)
-                assert_allclose(np.mean(f["data"]), 0.005883, atol=1e-6)
-                assert_allclose(np.sum(f["data"]), 7.954298, atol=1e-6)
+                assert_allclose(np.mean(f["data"]), 0.005883, atol=1e-6, rtol=1e-6)
+                assert_allclose(np.sum(f["data"]), 7.954298, atol=1e-6, rtol=1e-6)
 
     log_files = list(filter(lambda x: ".log" in x, files))
     assert len(log_files) == 1
@@ -616,10 +642,12 @@ def test_diad_testing_pipeline_output(
     assert "The full dataset shape is (3201, 22, 26)" in log_contents
     assert "Loading data: tests/test_data/k11_diad/k11-18014.nxs" in log_contents
     assert "Path to data: /entry/imaging/data" in log_contents
-    assert "Preview: (100:3101, 5:7:, :)" in log_contents
+    assert "Preview: (100:3101, 5:7, 0:26)" in log_contents
     assert "Data shape is (3001, 2, 26) of type uint16" in log_contents
-    assert "Saving intermediate file: 2-tomopy-normalize-tomo.h5" in log_contents
-    assert "Reslicing not necessary, as there is only one process" in log_contents
+    assert (
+        "Running save_task_1 (pattern=projection): save_intermediate_data..."
+        in log_contents
+    )
 
 
 def test_run_diad_pipeline_gpu(cmd, diad_data, diad_pipeline_gpu, output_folder):
@@ -645,11 +673,15 @@ def test_run_diad_pipeline_gpu(cmd, diad_data, diad_pipeline_gpu, output_folder)
     assert "The full dataset shape is (3201, 22, 26)" in log_contents
     assert "Loading data: tests/test_data/k11_diad/k11-18014.nxs" in log_contents
     assert "Path to data: /entry/imaging/data" in log_contents
-    assert "Preview: (100:3101, 8:15:, :)" in log_contents
+    assert "Preview: (100:3101, 8:15, 0:26)" in log_contents
     assert "Data shape is (3001, 7, 26) of type uint16" in log_contents
-    assert "Saving intermediate file: 5-httomolibgpu-FBP-tomo.h5" in log_contents
-    assert "Global min -0.00561" in log_contents
-    assert "Global max 0.006643" in log_contents
+    assert (
+        "Running save_task_5 (pattern=sinogram): save_intermediate_data..."
+        in log_contents
+    )
+    assert "Global min -0.011995" in log_contents
+    assert "Global max 0.019879" in log_contents
+    assert "Global mean 0.000291" in log_contents
 
 
 def test_run_pipeline_360deg_gpu2(cmd, data360, yaml_gpu_pipeline360_2, output_folder):
@@ -676,6 +708,10 @@ def test_run_pipeline_360deg_gpu2(cmd, data360, yaml_gpu_pipeline360_2, output_f
     assert "Loading data: tests/test_data/360scan/360scan.hdf" in log_contents
     assert "Path to data: entry1/tomo_entry/data/data" in log_contents
     assert "Data shape is (3601, 3, 2560) of type uint16" in log_contents
-    assert "Saving intermediate file: 5-httomolibgpu-FBP-tomo.h5" in log_contents
-    assert "Global min -0.000412" in log_contents
-    assert "Global max 0.003134" in log_contents
+    assert (
+        "Running save_task_6 (pattern=sinogram): save_intermediate_data..."
+        in log_contents
+    )
+    assert "Global min -0.00315" in log_contents
+    assert "Global max 0.00575" in log_contents
+    assert "Global mean 0.00088" in log_contents
diff --git a/tests/test_preview.py b/tests/test_preview.py
new file mode 100644
index 000000000..dda95ca4a
--- /dev/null
+++ b/tests/test_preview.py
@@ -0,0 +1,201 @@
+from pathlib import Path
+from typing import Tuple
+
+import pytest
+import h5py
+import numpy as np
+
+from httomo.preview import Preview, PreviewConfig, PreviewDimConfig
+
+
+@pytest.mark.parametrize(
+    "preview_config, is_error_expected, err_str",
+    [
+        (
+            PreviewConfig(
+                angles=PreviewDimConfig(start=0, stop=221),
+                detector_y=PreviewDimConfig(start=0, stop=128),
+                detector_x=PreviewDimConfig(start=0, stop=160),
+            ),
+            True,
+            "Preview indices in angles dim exceed bounds of data: start=0, stop=221",
+        ),
+        (
+            PreviewConfig(
+                angles=PreviewDimConfig(start=0, stop=220),
+                detector_y=PreviewDimConfig(start=0, stop=129),
+                detector_x=PreviewDimConfig(start=0, stop=160),
+            ),
+            True,
+            "Preview indices in detector_y dim exceed bounds of data: start=0, stop=129",
+        ),
+        (
+            PreviewConfig(
+                angles=PreviewDimConfig(start=0, stop=220),
+                detector_y=PreviewDimConfig(start=0, stop=128),
+                detector_x=PreviewDimConfig(start=0, stop=161),
+            ),
+            True,
+            "Preview indices in detector_x dim exceed bounds of data: start=0, stop=161",
+        ),
+        (
+            PreviewConfig(
+                angles=PreviewDimConfig(start=220, stop=220),
+                detector_y=PreviewDimConfig(start=0, stop=128),
+                detector_x=PreviewDimConfig(start=0, stop=160),
+            ),
+            True,
+            (
+                "Preview index error for angles: start must be strictly smaller than "
+                "stop, but start=220, stop=220"
+            ),
+        ),
+        (
+            PreviewConfig(
+                angles=PreviewDimConfig(start=0, stop=220),
+                detector_y=PreviewDimConfig(start=60, stop=50),
+                detector_x=PreviewDimConfig(start=0, stop=160),
+            ),
+            True,
+            (
+                "Preview index error for detector_y: start must be strictly smaller than "
+                "stop, but start=60, stop=50"
+            ),
+        ),
+        (
+            PreviewConfig(
+                angles=PreviewDimConfig(start=0, stop=220),
+                detector_y=PreviewDimConfig(start=0, stop=128),
+                detector_x=PreviewDimConfig(start=50, stop=0),
+            ),
+            True,
+            (
+                "Preview index error for detector_x: start must be strictly smaller than "
+                "stop, but start=50, stop=0"
+            ),
+        ),
+        (
+            PreviewConfig(
+                angles=PreviewDimConfig(start=0, stop=220),
+                detector_y=PreviewDimConfig(start=0, stop=128),
+                detector_x=PreviewDimConfig(start=0, stop=160),
+            ),
+            False,
+            "",
+        ),
+    ],
+    ids=[
+        "incorrect_angles_bounds",
+        "incorrect_det_y_bounds",
+        "incorrect_det_x_bounds",
+        "start_geq_stop_det_angles_bounds",
+        "start_geq_stop_det_y_bounds",
+        "start_geq_stop_det_x_bounds",
+        "all_correct_bounds",
+    ],
+)
+def test_preview_bound_checking(
+    standard_data_path: str,
+    standard_image_key_path: str,
+    preview_config: PreviewConfig,
+    is_error_expected: bool,
+    err_str: str,
+):
+    IN_FILE = Path(__file__).parent / "test_data/tomo_standard.nxs"
+    f = h5py.File(IN_FILE, "r")
+    dataset = f[standard_data_path]
+    image_key = f[standard_image_key_path]
+
+    if is_error_expected:
+        with pytest.raises(ValueError, match=err_str):
+            _ = Preview(
+                preview_config=preview_config,
+                dataset=dataset,
+                image_key=image_key,
+            )
+    else:
+        preview = Preview(
+            preview_config=preview_config,
+            dataset=dataset,
+            image_key=image_key,
+        )
+        assert preview.config == preview_config
+
+    f.close()
+
+
+def test_preview_calculate_data_indices_excludes_darks_flats(
+    standard_data_path: str,
+    standard_image_key_path: str,
+):
+    IN_FILE_PATH = Path(__file__).parent / "test_data/tomo_standard.nxs"
+    f = h5py.File(IN_FILE_PATH, "r")
+    dataset = f[standard_data_path]
+    image_key = f[standard_image_key_path]
+    all_indices: np.ndarray = image_key[:]
+    data_indices = np.where(all_indices == 0)[0]
+
+    config = PreviewConfig(
+        angles=PreviewDimConfig(start=0, stop=220),
+        detector_y=PreviewDimConfig(start=0, stop=128),
+        detector_x=PreviewDimConfig(start=0, stop=160),
+    )
+    preview = Preview(
+        preview_config=config,
+        dataset=dataset,
+        image_key=image_key,
+    )
+    assert not np.array_equal(preview.data_indices, all_indices)
+    assert np.array_equal(preview.data_indices, data_indices)
+    assert preview.config.angles == PreviewDimConfig(start=0, stop=180)
+    f.close()
+
+
+def test_preview_with_no_image_key():
+    IN_FILE_PATH = (
+        Path(__file__).parent
+        / "test_data/i12/separate_flats_darks/i12_dynamic_start_stop180.nxs"
+    )
+    f = h5py.File(IN_FILE_PATH, "r")
+    DATA_PATH = "1-TempPlugin-tomo/data"
+    ANGLES, DET_Y, DET_X = (724, 10, 192)
+    expected_indices = list(range(ANGLES))
+    config = PreviewConfig(
+        angles=PreviewDimConfig(start=0, stop=ANGLES),
+        detector_y=PreviewDimConfig(start=0, stop=DET_Y),
+        detector_x=PreviewDimConfig(start=0, stop=DET_X),
+    )
+    preview = Preview(
+        preview_config=config,
+        dataset=f[DATA_PATH],
+        image_key=None,
+    )
+    assert np.array_equal(preview.data_indices, expected_indices)
+
+
+@pytest.mark.parametrize(
+    "previewed_shape",
+    [(100, 128, 160), (180, 10, 160), (180, 128, 10)],
+    ids=["crop_angles_dim", "crop_det_y_dim", "crop_det_x_dim"],
+)
+def test_preview_global_shape(
+    standard_data_path: str,
+    standard_image_key_path: str,
+    previewed_shape: Tuple[int, int, int],
+):
+    IN_FILE_PATH = Path(__file__).parent / "test_data/tomo_standard.nxs"
+    f = h5py.File(IN_FILE_PATH, "r")
+    dataset = f[standard_data_path]
+    image_key = f[standard_image_key_path]
+
+    config = PreviewConfig(
+        angles=PreviewDimConfig(start=0, stop=previewed_shape[0]),
+        detector_y=PreviewDimConfig(start=0, stop=previewed_shape[1]),
+        detector_x=PreviewDimConfig(start=0, stop=previewed_shape[2]),
+    )
+    preview = Preview(
+        preview_config=config,
+        dataset=dataset,
+        image_key=image_key,
+    )
+    assert preview.global_shape == previewed_shape
diff --git a/tests/test_reslice.py b/tests/test_reslice.py
index 5a980a5f0..57b365159 100644
--- a/tests/test_reslice.py
+++ b/tests/test_reslice.py
@@ -62,7 +62,7 @@ def test_reslice(full_shape, current_slice_dim, next_slice_dim, dtype):
     # reslice, artificially mocking MPI max size to check that it can handle sizes
     # larger than max
     with mock.patch("httomo.data.mpiutil._mpi_max_elements", 128):
-        newdata, _ = reslice(data, current_slice_dim, next_slice_dim, comm)
+        newdata, _, start_idx = reslice(data, current_slice_dim, next_slice_dim, comm)
 
     # check expected dimensions
     start = round((full_shape[next_slice_dim - 1] / comm.size) * comm.rank)
@@ -70,6 +70,7 @@ def test_reslice(full_shape, current_slice_dim, next_slice_dim, dtype):
     expected_dims = np.copy(full_shape)
     expected_dims[next_slice_dim - 1] = stop - start
     np.testing.assert_array_equal(newdata.shape, expected_dims)
+    assert start == start_idx
 
     # check expected data
     expected = np.ones(expected_dims, dtype=dtype)
diff --git a/tests/test_transform_layer.py b/tests/test_transform_layer.py
new file mode 100644
index 000000000..5a5dd02cf
--- /dev/null
+++ b/tests/test_transform_layer.py
@@ -0,0 +1,142 @@
+from pathlib import Path
+import pytest
+from pytest_mock import MockerFixture
+from httomo.runner.loader import LoaderInterface
+from httomo.runner.pipeline import Pipeline
+from httomo.transform_layer import TransformLayer
+from .testing_utils import make_mock_repo, make_test_method
+from mpi4py import MPI
+
+
+def test_insert_save_methods(mocker: MockerFixture, tmp_path: Path):
+    comm = MPI.COMM_SELF
+    repo = make_mock_repo(mocker)
+    loader = mocker.create_autospec(
+        LoaderInterface,
+        instance=True,
+    )
+    pipeline = Pipeline(
+        loader=loader,
+        methods=[
+            make_test_method(mocker, method_name="m1", save_result=True, task_id="t1"),
+            make_test_method(mocker, method_name="m2", save_result=False),
+        ],
+    )
+    trans = TransformLayer(repo, comm=comm, save_all=False, out_dir=tmp_path)
+    pipeline = trans.insert_save_methods(pipeline)
+
+    assert len(pipeline) == 3
+    assert pipeline[1].method_name == "save_intermediate_data"
+    assert pipeline[1].task_id == "save_t1"
+
+
+def test_insert_save_methods_save_all(mocker: MockerFixture, tmp_path: Path):
+    comm = MPI.COMM_SELF
+    repo = make_mock_repo(mocker)
+    loader = mocker.create_autospec(
+        LoaderInterface,
+        instance=True,
+    )
+    pipeline = Pipeline(
+        loader=loader,
+        methods=[
+            make_test_method(mocker, method_name="m1", save_result=False, task_id="t1"),
+            make_test_method(mocker, method_name="m2", save_result=False, task_id="t2"),
+        ],
+    )
+    trans = TransformLayer(repo, comm=comm, save_all=True, out_dir=tmp_path)
+    pipeline = trans.insert_save_methods(pipeline)
+
+    assert len(pipeline) == 4
+    assert pipeline[1].method_name == "save_intermediate_data"
+    assert pipeline[1].task_id == "save_t1"
+    assert pipeline[3].method_name == "save_intermediate_data"
+    assert pipeline[3].task_id == "save_t2"
+
+
+@pytest.mark.parametrize("method_name", ["save_to_images", "find_center_vo"])
+def test_insert_save_methods_does_not_save_for_save_to_images(
+    mocker: MockerFixture, method_name: str, tmp_path: Path
+):
+    comm = MPI.COMM_SELF
+    repo = make_mock_repo(mocker)
+    loader = mocker.create_autospec(
+        LoaderInterface,
+        instance=True,
+    )
+    pipeline = Pipeline(
+        loader=loader,
+        methods=[
+            make_test_method(mocker, method_name="m1", save_result=False, task_id="t1"),
+            make_test_method(
+                mocker, method_name=method_name, save_result=True, task_id="t2"
+            ),
+        ],
+    )
+    trans = TransformLayer(repo, comm=comm, save_all=False, out_dir=tmp_path)
+    pipeline = trans.insert_save_methods(pipeline)
+
+    assert len(pipeline) == 2
+
+
+def test_insert_save_methods_does_nothing_if_no_save(
+    mocker: MockerFixture, tmp_path: Path
+):
+    comm = MPI.COMM_SELF
+    repo = make_mock_repo(mocker)
+    loader = mocker.create_autospec(
+        LoaderInterface,
+        instance=True,
+    )
+    pipeline = Pipeline(
+        loader=loader,
+        methods=[
+            make_test_method(mocker, method_name="m1", save_result=False, task_id="t1"),
+            make_test_method(mocker, method_name="m2", save_result=False, task_id="t2"),
+        ],
+    )
+    trans = TransformLayer(repo, comm=comm, save_all=False, out_dir=tmp_path)
+    pipeline = trans.insert_save_methods(pipeline)
+
+    assert len(pipeline) == 2
+
+
+def test_transform_calls_insert_save_methods(mocker: MockerFixture, tmp_path: Path):
+    comm = MPI.COMM_SELF
+    loader = mocker.create_autospec(
+        LoaderInterface,
+        instance=True,
+    )
+    pipeline = Pipeline(
+        loader=loader,
+        methods=[],
+    )
+    trans = TransformLayer(comm=comm, save_all=False, out_dir=tmp_path)
+    save_mock = mocker.patch.object(trans, "insert_save_methods", return_value=pipeline)
+    trans.transform(pipeline)
+
+    save_mock.assert_called_once()
+
+
+def test_insert_data_reducer(mocker: MockerFixture, tmp_path: Path):
+    comm = MPI.COMM_SELF
+    repo = make_mock_repo(mocker)
+    loader = mocker.create_autospec(
+        LoaderInterface,
+        instance=True,
+    )
+    pipeline = Pipeline(
+        loader=loader,
+        methods=[
+            make_test_method(
+                mocker, method_name="remove_outlier", save_result=False, task_id="t1"
+            ),
+            make_test_method(mocker, method_name="normalize", save_result=False),
+        ],
+    )
+    trans = TransformLayer(repo, comm=comm, save_all=False, out_dir=tmp_path)
+    pipeline = trans.insert_data_reducer(pipeline)
+
+    assert len(pipeline) == 3
+    assert pipeline[0].method_name == "data_reducer"
+    assert pipeline[0].task_id == "task_0"
diff --git a/tests/test_transform_loader_params.py b/tests/test_transform_loader_params.py
new file mode 100644
index 000000000..81a83de42
--- /dev/null
+++ b/tests/test_transform_loader_params.py
@@ -0,0 +1,255 @@
+import pytest
+
+from httomo.loaders.types import (
+    AnglesConfig,
+    RawAngles,
+    UserDefinedAngles,
+)
+from httomo.preview import PreviewConfig, PreviewDimConfig
+from httomo.transform_loader_params import (
+    AnglesParam,
+    PreviewParam,
+    parse_angles,
+    parse_preview,
+)
+
+
+@pytest.mark.parametrize(
+    "data_shape, preview_param_value, expected_preview_config",
+    [
+        (
+            (220, 128, 160),
+            None,
+            PreviewConfig(
+                angles=PreviewDimConfig(start=0, stop=220),
+                detector_y=PreviewDimConfig(start=0, stop=128),
+                detector_x=PreviewDimConfig(start=0, stop=160),
+            ),
+        ),
+        (
+            (220, 128, 160),
+            {
+                "angles": {"start": 0, "stop": 220},
+                "detector_y": {"start": 0, "stop": 128},
+                "detector_x": {"start": 0, "stop": 160},
+            },
+            PreviewConfig(
+                angles=PreviewDimConfig(start=0, stop=220),
+                detector_y=PreviewDimConfig(start=0, stop=128),
+                detector_x=PreviewDimConfig(start=0, stop=160),
+            ),
+        ),
+        (
+            (220, 128, 160),
+            {"angles": None, "detector_y": None, "detector_x": None},
+            PreviewConfig(
+                angles=PreviewDimConfig(start=0, stop=220),
+                detector_y=PreviewDimConfig(start=0, stop=128),
+                detector_x=PreviewDimConfig(start=0, stop=160),
+            ),
+        ),
+        (
+            (220, 128, 160),
+            {
+                "angles": None,
+                "detector_y": {"start": 0, "stop": 128},
+                "detector_x": {"start": 0, "stop": 160},
+            },
+            PreviewConfig(
+                angles=PreviewDimConfig(start=0, stop=220),
+                detector_y=PreviewDimConfig(start=0, stop=128),
+                detector_x=PreviewDimConfig(start=0, stop=160),
+            ),
+        ),
+        (
+            (220, 128, 160),
+            {
+                "angles": {"start": 0, "stop": 220},
+                "detector_y": None,
+                "detector_x": {"start": 0, "stop": 160},
+            },
+            PreviewConfig(
+                angles=PreviewDimConfig(start=0, stop=220),
+                detector_y=PreviewDimConfig(start=0, stop=128),
+                detector_x=PreviewDimConfig(start=0, stop=160),
+            ),
+        ),
+        (
+            (220, 128, 160),
+            {
+                "angles": {"start": 0, "stop": 220},
+                "detector_y": {"start": 0, "stop": 128},
+                "detector_x": None,
+            },
+            PreviewConfig(
+                angles=PreviewDimConfig(start=0, stop=220),
+                detector_y=PreviewDimConfig(start=0, stop=128),
+                detector_x=PreviewDimConfig(start=0, stop=160),
+            ),
+        ),
+        (
+            (220, 128, 160),
+            {
+                "detector_y": {"start": 0, "stop": 128},
+                "detector_x": {"start": 0, "stop": 160},
+            },
+            PreviewConfig(
+                angles=PreviewDimConfig(start=0, stop=220),
+                detector_y=PreviewDimConfig(start=0, stop=128),
+                detector_x=PreviewDimConfig(start=0, stop=160),
+            ),
+        ),
+        (
+            (220, 128, 160),
+            {
+                "angles": {"start": 0, "stop": 220},
+                "detector_x": {"start": 0, "stop": 160},
+            },
+            PreviewConfig(
+                angles=PreviewDimConfig(start=0, stop=220),
+                detector_y=PreviewDimConfig(start=0, stop=128),
+                detector_x=PreviewDimConfig(start=0, stop=160),
+            ),
+        ),
+        (
+            (220, 128, 160),
+            {
+                "angles": {"start": 0, "stop": 220},
+                "detector_y": {"start": 0, "stop": 128},
+            },
+            PreviewConfig(
+                angles=PreviewDimConfig(start=0, stop=220),
+                detector_y=PreviewDimConfig(start=0, stop=128),
+                detector_x=PreviewDimConfig(start=0, stop=160),
+            ),
+        ),
+        (
+            (220, 128, 160),
+            {
+                "angles": {"start": 0, "stop": 220},
+                "detector_y": "mid",
+                "detector_x": {"start": 0, "stop": 160},
+            },
+            PreviewConfig(
+                angles=PreviewDimConfig(start=0, stop=220),
+                detector_y=PreviewDimConfig(start=62, stop=65),
+                detector_x=PreviewDimConfig(start=0, stop=160),
+            ),
+        ),
+        (
+            (220, 127, 160),
+            {
+                "angles": {"start": 0, "stop": 220},
+                "detector_y": "mid",
+                "detector_x": {"start": 0, "stop": 160},
+            },
+            PreviewConfig(
+                angles=PreviewDimConfig(start=0, stop=220),
+                detector_y=PreviewDimConfig(start=62, stop=64),
+                detector_x=PreviewDimConfig(start=0, stop=160),
+            ),
+        ),
+        (
+            (220, 3, 160),
+            {
+                "angles": {"start": 0, "stop": 220},
+                "detector_y": "mid",
+                "detector_x": None,
+            },
+            PreviewConfig(
+                angles=PreviewDimConfig(start=0, stop=220),
+                detector_y=PreviewDimConfig(start=0, stop=3),
+                detector_x=PreviewDimConfig(start=0, stop=160),
+            ),
+        ),
+        (
+            (220, 128, 160),
+            {
+                "angles": {"start": 0, "stop": 220},
+                "detector_y": {"stop": 128},
+                "detector_x": {"start": 0, "stop": 160},
+            },
+            PreviewConfig(
+                angles=PreviewDimConfig(start=0, stop=220),
+                detector_y=PreviewDimConfig(start=0, stop=128),
+                detector_x=PreviewDimConfig(start=0, stop=160),
+            ),
+        ),
+        (
+            (220, 128, 160),
+            {
+                "angles": {"start": 0, "stop": 220},
+                "detector_y": {"start": 0},
+                "detector_x": {"start": 0, "stop": 160},
+            },
+            PreviewConfig(
+                angles=PreviewDimConfig(start=0, stop=220),
+                detector_y=PreviewDimConfig(start=0, stop=128),
+                detector_x=PreviewDimConfig(start=0, stop=160),
+            ),
+        ),
+    ],
+    ids=[
+        "preview_param_none",
+        "all_dims_provided",
+        "all_dims_none",
+        "angles_none",
+        "det_y_none",
+        "det_x_none",
+        "missing_angles",
+        "missing_det_y",
+        "missing_det_x",
+        "det_y_even_len_get_mid",
+        "det_y_odd_len_get_mid",
+        "det_y_small_len_get_mid",
+        "det_y_missing_start",
+        "det_y_missing_stop",
+    ],
+)
+def test_parse_preview(
+    data_shape: tuple[int, int, int],
+    preview_param_value: PreviewParam,
+    expected_preview_config: PreviewConfig,
+):
+    parsed_preview_config = parse_preview(preview_param_value, data_shape)
+    assert parsed_preview_config == expected_preview_config
+
+
+def test_parse_preview_raises_error_mid_in_angle_dim():
+    DATA_SHAPE = (220, 128, 160)
+    PREVIEW_PARAM_VALUE = {
+        "angles": "mid",
+        "detector_y": {"start": 0, "stop": 128},
+        "detector_x": {"start": 0, "stop": 160},
+    }
+    with pytest.raises(ValueError):
+        _ = parse_preview(PREVIEW_PARAM_VALUE, DATA_SHAPE)
+
+
+@pytest.mark.parametrize(
+    "angles_param, expected_angles_config",
+    [
+        (
+            {"data_path": "/entry1/tomo_entry/data/rotation_angle"},
+            RawAngles(data_path="/entry1/tomo_entry/data/rotation_angle"),
+        ),
+        (
+            {
+                "user_defined": {
+                    "start_angle": 0,
+                    "stop_angle": 180,
+                    "angles_total": 724,
+                },
+            },
+            UserDefinedAngles(
+                start_angle=0,
+                stop_angle=180,
+                angles_total=724,
+            ),
+        ),
+    ],
+    ids=["raw_angles", "user_defined_angles"],
+)
+def test_parse_angles(angles_param: AnglesParam, expected_angles_config: AnglesConfig):
+    angles_config = parse_angles(angles_param)
+    assert angles_config == expected_angles_config
diff --git a/tests/test_ui_layer.py b/tests/test_ui_layer.py
new file mode 100644
index 000000000..29872e672
--- /dev/null
+++ b/tests/test_ui_layer.py
@@ -0,0 +1,327 @@
+from pathlib import Path
+from mpi4py import MPI
+from pytest_mock import MockerFixture
+from httomo.runner.output_ref import OutputRef
+from httomo.ui_layer import UiLayer
+from httomo import ui_layer
+import pytest
+
+from .testing_utils import make_test_method
+
+# TODO: add files with invalid syntax
+
+
+@pytest.mark.parametrize("version", ["python", "yaml"])
+def test_can_read_cpu1_python(python_cpu_pipeline1, yaml_cpu_pipeline1, version):
+    if version == "python":
+        pipline_stage_config = ui_layer._python_tasks_loader(python_cpu_pipeline1)
+    else:
+        pipline_stage_config = ui_layer._yaml_loader(yaml_cpu_pipeline1)
+
+    assert len(pipline_stage_config) == 6
+    assert pipline_stage_config[0]["method"] == "standard_tomo"
+    assert pipline_stage_config[0]["module_path"] == "httomo.data.hdf.loaders"
+    assert pipline_stage_config[1]["method"] == "normalize"
+    assert pipline_stage_config[1]["module_path"] == "tomopy.prep.normalize"
+    assert pipline_stage_config[2]["method"] == "minus_log"
+    assert pipline_stage_config[2]["module_path"] == "tomopy.prep.normalize"
+    assert pipline_stage_config[3]["method"] == "find_center_vo"
+    assert pipline_stage_config[3]["module_path"] == "tomopy.recon.rotation"
+    assert pipline_stage_config[3]["side_outputs"] == {"cor": "centre_of_rotation"}
+    assert pipline_stage_config[4]["method"] == "recon"
+    assert pipline_stage_config[4]["module_path"] == "tomopy.recon.algorithm"
+    assert pipline_stage_config[5]["method"] == "save_to_images"
+    assert pipline_stage_config[5]["module_path"] == "httomolib.misc.images"
+
+
+@pytest.mark.parametrize("version", ["python", "yaml"])
+def test_can_read_cpu2_python(python_cpu_pipeline2, yaml_cpu_pipeline2, version):
+    if version == "python":
+        pipline_stage_config = ui_layer._python_tasks_loader(python_cpu_pipeline2)
+    else:
+        pipline_stage_config = ui_layer._yaml_loader(yaml_cpu_pipeline2)
+
+    assert len(pipline_stage_config) == 9
+    assert pipline_stage_config[0]["method"] == "standard_tomo"
+    assert pipline_stage_config[0]["module_path"] == "httomo.data.hdf.loaders"
+    assert pipline_stage_config[0]["parameters"]["preview"] == {
+        "detector_y": {"start": 30, "stop": 60}
+    }
+    assert pipline_stage_config[1]["method"] == "find_center_vo"
+    assert pipline_stage_config[1]["module_path"] == "tomopy.recon.rotation"
+    assert pipline_stage_config[1]["side_outputs"] == {"cor": "centre_of_rotation"}
+    assert pipline_stage_config[2]["method"] == "remove_outlier"
+    assert pipline_stage_config[2]["module_path"] == "tomopy.misc.corr"
+    assert pipline_stage_config[3]["method"] == "normalize"
+    assert pipline_stage_config[3]["module_path"] == "tomopy.prep.normalize"
+    assert pipline_stage_config[4]["method"] == "minus_log"
+    assert pipline_stage_config[4]["module_path"] == "tomopy.prep.normalize"
+    assert pipline_stage_config[5]["method"] == "remove_stripe_fw"
+    assert pipline_stage_config[5]["module_path"] == "tomopy.prep.stripe"
+    assert pipline_stage_config[6]["method"] == "recon"
+    assert pipline_stage_config[6]["module_path"] == "tomopy.recon.algorithm"
+    assert pipline_stage_config[6]["save_result"] == True
+    assert pipline_stage_config[7]["method"] == "median_filter"
+    assert pipline_stage_config[7]["module_path"] == "tomopy.misc.corr"
+    assert pipline_stage_config[8]["method"] == "save_to_images"
+    assert pipline_stage_config[8]["module_path"] == "httomolib.misc.images"
+
+
+@pytest.mark.parametrize("version", ["python", "yaml"])
+def test_can_read_cpu3_python(python_cpu_pipeline3, yaml_cpu_pipeline3, version):
+    if version == "python":
+        pipline_stage_config = ui_layer._python_tasks_loader(python_cpu_pipeline3)
+    else:
+        pipline_stage_config = ui_layer._yaml_loader(yaml_cpu_pipeline3)
+
+    assert len(pipline_stage_config) == 8
+    assert pipline_stage_config[0]["method"] == "standard_tomo"
+    assert pipline_stage_config[0]["module_path"] == "httomo.data.hdf.loaders"
+    assert pipline_stage_config[1]["method"] == "find_center_vo"
+    assert pipline_stage_config[1]["module_path"] == "tomopy.recon.rotation"
+    assert pipline_stage_config[1]["side_outputs"] == {"cor": "centre_of_rotation"}
+    assert pipline_stage_config[2]["method"] == "remove_outlier"
+    assert pipline_stage_config[2]["module_path"] == "tomopy.misc.corr"
+    assert pipline_stage_config[3]["method"] == "normalize"
+    assert pipline_stage_config[3]["module_path"] == "tomopy.prep.normalize"
+    assert pipline_stage_config[4]["method"] == "minus_log"
+    assert pipline_stage_config[4]["module_path"] == "tomopy.prep.normalize"
+    assert pipline_stage_config[5]["method"] == "recon"
+    assert pipline_stage_config[5]["module_path"] == "tomopy.recon.algorithm"
+    assert pipline_stage_config[6]["method"] == "calculate_stats"
+    assert pipline_stage_config[6]["module_path"] == "httomo.methods"
+    assert pipline_stage_config[6]["side_outputs"] == {"glob_stats": "glob_stats"}
+    assert pipline_stage_config[7]["method"] == "save_to_images"
+    assert pipline_stage_config[7]["module_path"] == "httomolib.misc.images"
+
+
+@pytest.mark.parametrize("version", ["python", "yaml"])
+def test_can_read_gpu1_python(python_gpu_pipeline1, yaml_gpu_pipeline1, version):
+    if version == "python":
+        pipline_stage_config = ui_layer._python_tasks_loader(python_gpu_pipeline1)
+    else:
+        pipline_stage_config = ui_layer._yaml_loader(yaml_gpu_pipeline1)
+
+    assert len(pipline_stage_config) == 7
+    assert pipline_stage_config[0]["method"] == "standard_tomo"
+    assert pipline_stage_config[0]["module_path"] == "httomo.data.hdf.loaders"
+    assert pipline_stage_config[1]["method"] == "find_center_vo"
+    assert pipline_stage_config[1]["module_path"] == "httomolibgpu.recon.rotation"
+    assert pipline_stage_config[1]["side_outputs"] == {"cor": "centre_of_rotation"}
+    assert pipline_stage_config[2]["method"] == "remove_outlier"
+    assert pipline_stage_config[2]["module_path"] == "httomolibgpu.misc.corr"
+    assert pipline_stage_config[3]["method"] == "normalize"
+    assert pipline_stage_config[3]["module_path"] == "httomolibgpu.prep.normalize"
+    assert pipline_stage_config[4]["method"] == "remove_stripe_based_sorting"
+    assert pipline_stage_config[4]["module_path"] == "httomolibgpu.prep.stripe"
+    assert pipline_stage_config[5]["method"] == "FBP"
+    assert pipline_stage_config[5]["module_path"] == "httomolibgpu.recon.algorithm"
+    assert pipline_stage_config[6]["method"] == "save_to_images"
+    assert pipline_stage_config[6]["module_path"] == "httomolib.misc.images"
+
+
+@pytest.mark.parametrize("extension", ["yaml", "yml", "YaML", "YAML"])
+def test_uilayer_calls_correct_loader_yaml(mocker: MockerFixture, extension: str):
+    comm = MPI.COMM_NULL
+    loader = mocker.patch("httomo.ui_layer._yaml_loader")
+    file = Path(f"test_pipeline.{extension}")
+    UiLayer(file, Path("doesnt_matter"), comm=comm)
+
+    loader.assert_called_once_with(file)
+
+
+@pytest.mark.parametrize("extension", ["py", "PY", "Py"])
+def test_uilayer_calls_correct_loader_python(mocker: MockerFixture, extension: str):
+    comm = MPI.COMM_NULL
+    loader = mocker.patch("httomo.ui_layer._python_tasks_loader")
+    file = Path(f"test_pipeline.{extension}")
+    UiLayer(file, Path("doesnt_matter"), comm=comm)
+
+    loader.assert_called_once_with(file)
+
+
+def test_uilayer_fails_with_unsupported_extension(mocker: MockerFixture):
+    comm = MPI.COMM_NULL
+    file = Path(f"test_pipeline.dummy")
+    with pytest.raises(ValueError) as e:
+        UiLayer(file, Path("doesnt_matter"), comm=comm)
+
+    assert "extension .dummy" in str(e)
+
+
+@pytest.mark.parametrize("file", ["does_not_exist.py", "does_not_exist.yaml"])
+def test_uilayer_fails_with_nonexistant_file(file: str):
+    comm = MPI.COMM_NULL
+    with pytest.raises(FileNotFoundError):
+        UiLayer(Path(file), Path("doesnt_matter"), comm=comm)
+
+
+def test_pipeline_build_no_loader(python_cpu_pipeline1, standard_data):
+    comm = MPI.COMM_NULL
+    LayerUI = UiLayer(
+        python_cpu_pipeline1,
+        standard_data,
+        comm=comm,
+    )
+    del LayerUI.PipelineStageConfig[0]
+    with pytest.raises(ValueError) as e:
+        LayerUI.build_pipeline()
+
+    assert "no loader" in str(e)
+
+
+def test_pipeline_build_duplicate_id(python_cpu_pipeline1, standard_data):
+    comm = MPI.COMM_NULL
+    LayerUI = UiLayer(
+        python_cpu_pipeline1,
+        standard_data,
+        comm=comm,
+    )
+    LayerUI.PipelineStageConfig[1]["id"] = "testid"
+    LayerUI.PipelineStageConfig[2]["id"] = "testid"
+    with pytest.raises(ValueError) as e:
+        LayerUI.build_pipeline()
+
+    assert "duplicate id" in str(e)
+
+
+@pytest.mark.parametrize("version", ["python", "yaml"])
+def test_pipeline_build_cpu1(
+    standard_data, python_cpu_pipeline1, yaml_cpu_pipeline1, version: str
+):
+    """Testing OutputRef."""
+    comm = MPI.COMM_WORLD
+    LayerUI = UiLayer(
+        python_cpu_pipeline1 if version == "python" else yaml_cpu_pipeline1,
+        standard_data,
+        comm=comm,
+    )
+
+    pipeline = LayerUI.build_pipeline()
+
+    assert len(pipeline) == 5
+    methods = ["normalize", "minus_log", "find_center_vo", "recon", "save_to_images"]
+    for i in range(5):
+        assert pipeline[i].method_name == methods[i]
+        if i != 2:
+            assert pipeline[i].task_id == f"task_{i+1}"
+        else:
+            assert pipeline[i].task_id == "centering"
+
+    ref = pipeline[3]["center"]
+    assert isinstance(ref, OutputRef)
+    assert ref.mapped_output_name == "centre_of_rotation"
+    assert ref.method.method_name == "find_center_vo"
+
+
+@pytest.mark.parametrize("version", ["python", "yaml"])
+def test_pipeline_build_cpu2(
+    standard_data, python_cpu_pipeline2, yaml_cpu_pipeline2, version: str
+):
+    """Testing OutputRef."""
+    comm = MPI.COMM_WORLD
+    LayerUI = UiLayer(
+        python_cpu_pipeline2 if version == "python" else yaml_cpu_pipeline2,
+        standard_data,
+        comm=comm,
+    )
+
+    pipeline = LayerUI.build_pipeline()
+
+    assert len(pipeline) == 8
+    methods = [
+        "find_center_vo",
+        "remove_outlier",
+        "normalize",
+        "minus_log",
+        "remove_stripe_fw",
+        "recon",
+        "median_filter",
+        "save_to_images",
+    ]
+    for i in range(8):
+        assert pipeline[i].method_name == methods[i]
+    ref = pipeline[5]["center"]
+    assert isinstance(ref, OutputRef)
+    assert ref.mapped_output_name == "centre_of_rotation"
+    assert ref.method.method_name == "find_center_vo"
+    assert ref.method.task_id == "centering"
+
+
+@pytest.mark.parametrize("version", ["python", "yaml"])
+def test_pipeline_build_cpu3(
+    standard_data, python_cpu_pipeline3, yaml_cpu_pipeline3, version: str
+):
+    """Testing OutputRef."""
+    comm = MPI.COMM_WORLD
+    LayerUI = UiLayer(
+        python_cpu_pipeline3 if version == "python" else yaml_cpu_pipeline3,
+        standard_data,
+        comm=comm,
+    )
+
+    pipeline = LayerUI.build_pipeline()
+
+    assert len(pipeline) == 7
+    methods = [
+        "find_center_vo",
+        "remove_outlier",
+        "normalize",
+        "minus_log",
+        "recon",
+        "calculate_stats",
+        "save_to_images",
+    ]
+    for i in range(7):
+        assert pipeline[i].method_name == methods[i]
+    ref = pipeline[6]["glob_stats"]
+    assert isinstance(ref, OutputRef)
+    assert ref.mapped_output_name == "glob_stats"
+    assert ref.method.method_name == "calculate_stats"
+    assert ref.method.task_id == "statistics"
+
+
+@pytest.mark.parametrize(
+    "refvalue",
+    [
+        "no_ref",
+        "as ${{mixed_with_other}}",
+        "${{mixed_with_other}} asd",
+        "${{}}",
+        "${{with spaces}}",
+        "${{inV-1%^&valid_chars}}",
+    ],
+)
+def test_update_side_output_references_invalid(refvalue: str):
+    parameters = {"refkey": refvalue}
+    ui_layer._update_side_output_references(parameters, {})
+
+    assert parameters == {"refkey": refvalue}  # not updated
+
+
+def test_update_side_output_references_normal(mocker: MockerFixture):
+    parameters = {"refkey": "${{testid.side_outputs.value}}"}
+    ui_layer._update_side_output_references(
+        parameters, dict(testid=make_test_method(mocker))
+    )
+
+    obj = parameters["refkey"]
+    assert isinstance(obj, OutputRef)
+    assert obj.mapped_output_name == "value"
+
+
+def test_update_side_output_references_nosidestr():
+    parameters = {"refkey": "${{testid.side_typo_outputs.value}}"}
+    with pytest.raises(ValueError) as e:
+        ui_layer._update_side_output_references(parameters, {})
+    assert "side_outputs" in str(e)
+
+
+def test_update_side_output_references_notfound(mocker: MockerFixture):
+    parameters = {"refkey": "${{testid123.side_outputs.value}}"}
+    with pytest.raises(ValueError) as e:
+        ui_layer._update_side_output_references(
+            parameters, dict(testid=make_test_method(mocker))
+        )
+
+    assert "could not find method referenced" in str(e)
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 000000000..6769b5be1
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,21 @@
+from time import sleep
+from httomo.utils import catchtime, xp, catch_gputime, gpu_enabled
+
+
+def test_cachetime():
+    with catchtime() as t:
+        sleep(0.1)
+        
+    assert t.elapsed >= 0.1
+    assert t.elapsed < 0.2
+
+
+def test_catch_gputime():
+    input = xp.ones((500, 200, 100), dtype=xp.float32)
+    with catch_gputime() as t:
+        xp.sum(input)
+        
+    if gpu_enabled:
+        assert t.elapsed > 0.0
+    else:
+        assert t.elapsed == 0.0
\ No newline at end of file
diff --git a/tests/testing_utils.py b/tests/testing_utils.py
new file mode 100644
index 000000000..46a8370bf
--- /dev/null
+++ b/tests/testing_utils.py
@@ -0,0 +1,117 @@
+from typing import List, Optional
+from httomo.runner.method_wrapper import MethodWrapper
+from httomo.runner.dataset import DataSetBlock
+from httomo.runner.dataset_store_interfaces import DataSetSource
+from httomo.runner.loader import LoaderInterface
+from httomo.runner.methods_repository_interface import (
+    GpuMemoryRequirement,
+    MethodRepository,
+)
+from httomo.utils import Pattern
+
+from pytest_mock import MockerFixture
+
+
+def make_test_method(
+    mocker: MockerFixture,
+    gpu=False,
+    pattern=Pattern.projection,
+    method_name="testmethod",
+    module_path="testpath",
+    save_result=False,
+    task_id:Optional[str]=None,
+    **kwargs,
+) -> MethodWrapper:
+    if task_id is None:
+        task_id = f"task_{method_name}"
+    mock = mocker.create_autospec(
+        MethodWrapper,
+        instance=True,
+        method_name=method_name,
+        module_path=module_path,
+        pattern=pattern,
+        is_gpu=gpu,
+        is_cpu=not gpu,
+        save_result=save_result,
+        task_id=task_id,
+        config_params=kwargs,
+        __getitem__=lambda _, k: kwargs[k],  # return kwargs value from dict access
+    )
+
+    return mock
+
+
+def make_test_loader(
+    mocker: MockerFixture,
+    block: Optional[DataSetBlock] = None,
+    pattern: Pattern = Pattern.all,
+    method_name="testloader",
+) -> LoaderInterface:
+    interface: LoaderInterface = mocker.create_autospec(
+        LoaderInterface,
+        instance=True,
+        pattern=pattern,
+        method_name=method_name,
+        reslice=False,
+    )
+    if block is not None:
+
+        def mock_make_data_source() -> DataSetSource:
+            ret = mocker.create_autospec(
+                DataSetSource,
+                global_shape=block.global_shape,
+                dtype=block.data.dtype,
+                chunk_shape=block.chunk_shape,
+                chunk_index=block.chunk_index,
+                slicing_dim=1 if interface.pattern == Pattern.sinogram else 0,
+                aux_data=block.aux_data
+            )
+            slicing_dim=1 if interface.pattern == Pattern.sinogram else 0
+            mocker.patch.object(
+                ret,
+                "read_block",
+                side_effect=lambda start, length: DataSetBlock(
+                    data=block.data[start: start+length, :, :],
+                    aux_data=block.aux_data,
+                    global_shape=block.global_shape,
+                    chunk_shape=block.chunk_shape,
+                    slicing_dim=slicing_dim,
+                    block_start=start,
+                    chunk_start=block.chunk_index[slicing_dim]
+                )
+            )
+            
+            return ret
+
+        mocker.patch.object(
+            interface,
+            "make_data_source",
+            side_effect=mock_make_data_source,
+        )
+    return interface
+
+
+def make_mock_repo(
+    mocker: MockerFixture,
+    pattern=Pattern.sinogram,
+    output_dims_change: bool = False,
+    implementation: str = "cpu",
+    memory_gpu: List[GpuMemoryRequirement] = [
+        GpuMemoryRequirement(dataset="tomo", multiplier=1.2, method="direct")
+    ],
+    swap_dims_on_output=False,
+    save_result_default=False
+) -> MethodRepository:
+    """Makes a mock MethodRepository that returns the given properties on any query"""
+    mock_repo = mocker.MagicMock()
+    mock_query = mocker.MagicMock()
+    mocker.patch.object(mock_repo, "query", return_value=mock_query)
+    mocker.patch.object(mock_query, "get_pattern", return_value=pattern)
+    mocker.patch.object(
+        mock_query, "get_output_dims_change", return_value=output_dims_change
+    )
+    mocker.patch.object(mock_query, "swap_dims_on_output", return_value=swap_dims_on_output)
+    mocker.patch.object(mock_query, "get_implementation", return_value=implementation)
+    mocker.patch.object(mock_query, "get_memory_gpu_params", return_value=memory_gpu)
+    mocker.patch.object(mock_query, "save_result_default", return_value=save_result_default)
+    return mock_repo
diff --git a/tests/ui/test_ui_layer_py.py b/tests/ui/test_ui_layer_py.py
deleted file mode 100644
index 3e4751791..000000000
--- a/tests/ui/test_ui_layer_py.py
+++ /dev/null
@@ -1,166 +0,0 @@
-from mpi4py import MPI
-from httomo.ui_layer import UiLayer
-
-
-def test_pipeline_cpu1(standard_data, python_cpu_pipeline1):
-    """Testing existing python pipelines by reading them and generating Pipelines
-    """
-
-    comm = MPI.COMM_WORLD
-    LayerUI = UiLayer(python_cpu_pipeline1, standard_data, comm=comm)
-
-    pipeline = LayerUI.build_pipeline()
-
-    assert len(pipeline._methods) == 5
-    assert LayerUI.PipelineStageConfig[0]['method'] == 'standard_tomo'
-    assert LayerUI.PipelineStageConfig[0]['module_path'] == 'httomo.data.hdf.loaders'
-    assert LayerUI.PipelineStageConfig[1]['method'] == 'normalize'
-    assert LayerUI.PipelineStageConfig[1]['module_path'] == 'tomopy.prep.normalize'
-    assert LayerUI.PipelineStageConfig[2]['method'] == 'minus_log'
-    assert LayerUI.PipelineStageConfig[2]['module_path'] == 'tomopy.prep.normalize'
-    assert LayerUI.PipelineStageConfig[3]['method'] == 'find_center_vo'
-    assert LayerUI.PipelineStageConfig[3]['module_path'] == 'tomopy.recon.rotation'
-    assert LayerUI.PipelineStageConfig[3]['side_outputs'] == {'cor' : 'centre_of_rotation'}
-    assert LayerUI.PipelineStageConfig[4]['method'] == 'recon'
-    assert LayerUI.PipelineStageConfig[4]['module_path'] == 'tomopy.recon.algorithm'
-    assert LayerUI.PipelineStageConfig[5]['method'] == 'save_to_images'
-    assert LayerUI.PipelineStageConfig[5]['module_path'] == 'httomolib.misc.images'    
-
-def test_pipeline_cpu1_outref(standard_data, python_cpu_pipeline1):
-    """Testing OutputRef.
-    """
-    comm = MPI.COMM_WORLD
-    LayerUI = UiLayer(python_cpu_pipeline1, standard_data, comm=comm)
-
-    pipeline = LayerUI.build_pipeline()
-    
-    obj = LayerUI.PipelineStageConfig[4]['parameters']['center']
-    assert obj.mapped_output_name == 'centre_of_rotation'
-
-
-def test_pipeline_cpu2(standard_data, python_cpu_pipeline2):
-    """Testing existing python pipelines by reading them and generating Pipelines
-    """
-
-    comm = MPI.COMM_WORLD
-    LayerUI = UiLayer(python_cpu_pipeline2, standard_data, comm=comm)
-
-    pipeline = LayerUI.build_pipeline()
-
-    assert len(pipeline._methods) == 8
-    assert LayerUI.PipelineStageConfig[0]['method'] == 'standard_tomo'
-    assert LayerUI.PipelineStageConfig[0]['module_path'] == 'httomo.data.hdf.loaders'
-    assert LayerUI.PipelineStageConfig[0]['parameters']['preview'] == [None, {'start': 30, 'stop': 60}, None]
-    assert LayerUI.PipelineStageConfig[1]['method'] == 'find_center_vo'
-    assert LayerUI.PipelineStageConfig[1]['module_path'] == 'tomopy.recon.rotation'    
-    assert LayerUI.PipelineStageConfig[1]['side_outputs'] == {'cor' : 'centre_of_rotation'}
-    assert LayerUI.PipelineStageConfig[2]['method'] == 'remove_outlier'
-    assert LayerUI.PipelineStageConfig[2]['module_path'] == 'tomopy.misc.corr'    
-    assert LayerUI.PipelineStageConfig[3]['method'] == 'normalize'
-    assert LayerUI.PipelineStageConfig[3]['module_path'] == 'tomopy.prep.normalize'
-    assert LayerUI.PipelineStageConfig[4]['method'] == 'minus_log'
-    assert LayerUI.PipelineStageConfig[4]['module_path'] == 'tomopy.prep.normalize'
-    assert LayerUI.PipelineStageConfig[5]['method'] == 'remove_stripe_fw'
-    assert LayerUI.PipelineStageConfig[5]['module_path'] == 'tomopy.prep.stripe'
-    assert LayerUI.PipelineStageConfig[6]['method'] == 'recon'
-    assert LayerUI.PipelineStageConfig[6]['module_path'] == 'tomopy.recon.algorithm'
-    assert LayerUI.PipelineStageConfig[6]['save_result'] == True
-    assert LayerUI.PipelineStageConfig[7]['method'] == 'median_filter'
-    assert LayerUI.PipelineStageConfig[7]['module_path'] == 'tomopy.misc.corr'
-    assert LayerUI.PipelineStageConfig[8]['method'] == 'save_to_images'
-    assert LayerUI.PipelineStageConfig[8]['module_path'] == 'httomolib.misc.images'
-
-
-def test_pipeline_cpu2_outref(standard_data, python_cpu_pipeline2):
-    """Testing OutputRef.
-    """
-    comm = MPI.COMM_WORLD
-    LayerUI = UiLayer(python_cpu_pipeline2, standard_data, comm=comm)
-
-    pipeline = LayerUI.build_pipeline()
-    
-    obj = LayerUI.PipelineStageConfig[6]['parameters']['center']
-    assert obj.mapped_output_name == 'centre_of_rotation'
-
-
-def test_pipeline_cpu3(standard_data, python_cpu_pipeline3):
-    """Testing existing python pipelines by reading them and generating Pipelines
-    """
-
-    comm = MPI.COMM_WORLD
-    LayerUI = UiLayer(python_cpu_pipeline3, standard_data, comm=comm)
-
-    pipeline = LayerUI.build_pipeline()
-
-    assert len(pipeline._methods) == 7
-    assert LayerUI.PipelineStageConfig[0]['method'] == 'standard_tomo'
-    assert LayerUI.PipelineStageConfig[0]['module_path'] == 'httomo.data.hdf.loaders'
-    assert LayerUI.PipelineStageConfig[0]['parameters']['preview'] == [None, None, None]
-    assert LayerUI.PipelineStageConfig[1]['method'] == 'find_center_vo'
-    assert LayerUI.PipelineStageConfig[1]['module_path'] == 'tomopy.recon.rotation'    
-    assert LayerUI.PipelineStageConfig[1]['side_outputs'] == {'cor' : 'centre_of_rotation'}
-    assert LayerUI.PipelineStageConfig[2]['method'] == 'remove_outlier'
-    assert LayerUI.PipelineStageConfig[2]['module_path'] == 'tomopy.misc.corr'    
-    assert LayerUI.PipelineStageConfig[3]['method'] == 'normalize'
-    assert LayerUI.PipelineStageConfig[3]['module_path'] == 'tomopy.prep.normalize'
-    assert LayerUI.PipelineStageConfig[4]['method'] == 'minus_log'
-    assert LayerUI.PipelineStageConfig[4]['module_path'] == 'tomopy.prep.normalize'
-    assert LayerUI.PipelineStageConfig[5]['method'] == 'recon'
-    assert LayerUI.PipelineStageConfig[5]['module_path'] == 'tomopy.recon.algorithm'
-    assert LayerUI.PipelineStageConfig[6]['method'] == 'calculate_stats'
-    assert LayerUI.PipelineStageConfig[6]['module_path'] == 'httomo.methods'
-    assert LayerUI.PipelineStageConfig[6]['side_outputs'] == {'glob_stats' : 'glob_stats'}
-    assert LayerUI.PipelineStageConfig[7]['method'] == 'save_to_images'
-    assert LayerUI.PipelineStageConfig[7]['module_path'] == 'httomolib.misc.images'
-
-def test_pipeline_cpu3_outref(standard_data, python_cpu_pipeline3):
-    """Testing OutputRef.
-    """
-    comm = MPI.COMM_WORLD
-    LayerUI = UiLayer(python_cpu_pipeline3, standard_data, comm=comm)
-
-    pipeline = LayerUI.build_pipeline()
-
-    obj = LayerUI.PipelineStageConfig[7]['parameters']['glob_stats']
-    assert obj.mapped_output_name == 'glob_stats'
-
-
-
-def test_pipeline_gpu1(standard_data, python_gpu_pipeline1):
-    """Testing existing python pipelines by reading them and generating Pipelines
-    """
-
-    comm = MPI.COMM_WORLD
-    LayerUI = UiLayer(python_gpu_pipeline1, standard_data, comm=comm)
-
-    pipeline = LayerUI.build_pipeline()
-
-    assert len(pipeline._methods) == 6
-    assert LayerUI.PipelineStageConfig[0]['method'] == 'standard_tomo'
-    assert LayerUI.PipelineStageConfig[0]['module_path'] == 'httomo.data.hdf.loaders'
-    assert LayerUI.PipelineStageConfig[0]['parameters']['preview'] == [None, None, None]
-    assert LayerUI.PipelineStageConfig[1]['method'] == 'find_center_vo'
-    assert LayerUI.PipelineStageConfig[1]['module_path'] == 'httomolibgpu.recon.rotation'    
-    assert LayerUI.PipelineStageConfig[1]['side_outputs'] == {'cor' : 'centre_of_rotation'}
-    assert LayerUI.PipelineStageConfig[2]['method'] == 'remove_outlier3d'
-    assert LayerUI.PipelineStageConfig[2]['module_path'] == 'httomolibgpu.misc.corr'    
-    assert LayerUI.PipelineStageConfig[3]['method'] == 'normalize'
-    assert LayerUI.PipelineStageConfig[3]['module_path'] == 'httomolibgpu.prep.normalize'
-    assert LayerUI.PipelineStageConfig[4]['method'] == 'remove_stripe_based_sorting'
-    assert LayerUI.PipelineStageConfig[4]['module_path'] == 'httomolibgpu.prep.stripe'
-    assert LayerUI.PipelineStageConfig[5]['method'] == 'FBP'
-    assert LayerUI.PipelineStageConfig[5]['module_path'] == 'httomolibgpu.recon.algorithm'
-    assert LayerUI.PipelineStageConfig[6]['method'] == 'save_to_images'
-    assert LayerUI.PipelineStageConfig[6]['module_path'] == 'httomolib.misc.images'
-
-
-def test_pipeline_gpu1_outref(standard_data, python_gpu_pipeline1):
-    """Testing OutputRef.
-    """
-    comm = MPI.COMM_WORLD
-    LayerUI = UiLayer(python_gpu_pipeline1, standard_data, comm=comm)
-
-    pipeline = LayerUI.build_pipeline()
-    
-    obj = LayerUI.PipelineStageConfig[5]['parameters']['center']
-    assert obj.mapped_output_name == 'centre_of_rotation'    
\ No newline at end of file
diff --git a/tests/ui/test_ui_layer_yaml.py b/tests/ui/test_ui_layer_yaml.py
deleted file mode 100644
index d2e23a230..000000000
--- a/tests/ui/test_ui_layer_yaml.py
+++ /dev/null
@@ -1,164 +0,0 @@
-from mpi4py import MPI
-from httomo.ui_layer import UiLayer
-
-def test_pipeline_cpu1(standard_data, yaml_cpu_pipeline1):
-    """Testing existing yaml pipelines by reading them and generating Pipelines
-    """
-
-    comm = MPI.COMM_WORLD
-    LayerUI = UiLayer(yaml_cpu_pipeline1, standard_data, comm=comm)
-
-    pipeline = LayerUI.build_pipeline()
-
-    assert len(pipeline._methods) == 5
-    assert LayerUI.PipelineStageConfig[0]['method'] == 'standard_tomo'
-    assert LayerUI.PipelineStageConfig[0]['module_path'] == 'httomo.data.hdf.loaders'
-    assert LayerUI.PipelineStageConfig[1]['method'] == 'normalize'
-    assert LayerUI.PipelineStageConfig[1]['module_path'] == 'tomopy.prep.normalize'
-    assert LayerUI.PipelineStageConfig[2]['method'] == 'minus_log'
-    assert LayerUI.PipelineStageConfig[2]['module_path'] == 'tomopy.prep.normalize'
-    assert LayerUI.PipelineStageConfig[3]['method'] == 'find_center_vo'
-    assert LayerUI.PipelineStageConfig[3]['module_path'] == 'tomopy.recon.rotation'
-    assert LayerUI.PipelineStageConfig[3]['side_outputs'] == {'cor' : 'centre_of_rotation'}
-    assert LayerUI.PipelineStageConfig[4]['method'] == 'recon'
-    assert LayerUI.PipelineStageConfig[4]['module_path'] == 'tomopy.recon.algorithm'
-    assert LayerUI.PipelineStageConfig[5]['method'] == 'save_to_images'
-    assert LayerUI.PipelineStageConfig[5]['module_path'] == 'httomolib.misc.images'
-
-
-def test_pipeline_cpu1_outref(standard_data, yaml_cpu_pipeline1):
-    """Testing OutputRef.
-    """
-    comm = MPI.COMM_WORLD
-    LayerUI = UiLayer(yaml_cpu_pipeline1, standard_data, comm=comm)
-
-    pipeline = LayerUI.build_pipeline()
-    
-    obj = LayerUI.PipelineStageConfig[4]['parameters']['center']
-    assert obj.mapped_output_name == 'centre_of_rotation'
-
-
-def test_pipeline_cpu2_yaml(standard_data, yaml_cpu_pipeline2):
-    """Testing existing python pipelines by reading them and generating Pipelines
-    """
-
-    comm = MPI.COMM_WORLD
-    LayerUI = UiLayer(yaml_cpu_pipeline2, standard_data, comm=comm)
-
-    pipeline = LayerUI.build_pipeline()
-
-    assert len(pipeline._methods) == 8
-    assert LayerUI.PipelineStageConfig[0]['method'] == 'standard_tomo'
-    assert LayerUI.PipelineStageConfig[0]['module_path'] == 'httomo.data.hdf.loaders'
-    assert LayerUI.PipelineStageConfig[0]['parameters']['preview'] == [None, {'start': 30, 'stop': 60}, None]
-    assert LayerUI.PipelineStageConfig[1]['method'] == 'find_center_vo'
-    assert LayerUI.PipelineStageConfig[1]['module_path'] == 'tomopy.recon.rotation'    
-    assert LayerUI.PipelineStageConfig[1]['side_outputs'] == {'cor' : 'centre_of_rotation'}
-    assert LayerUI.PipelineStageConfig[2]['method'] == 'remove_outlier'
-    assert LayerUI.PipelineStageConfig[2]['module_path'] == 'tomopy.misc.corr'    
-    assert LayerUI.PipelineStageConfig[3]['method'] == 'normalize'
-    assert LayerUI.PipelineStageConfig[3]['module_path'] == 'tomopy.prep.normalize'
-    assert LayerUI.PipelineStageConfig[4]['method'] == 'minus_log'
-    assert LayerUI.PipelineStageConfig[4]['module_path'] == 'tomopy.prep.normalize'
-    assert LayerUI.PipelineStageConfig[5]['method'] == 'remove_stripe_fw'
-    assert LayerUI.PipelineStageConfig[5]['module_path'] == 'tomopy.prep.stripe'
-    assert LayerUI.PipelineStageConfig[6]['method'] == 'recon'
-    assert LayerUI.PipelineStageConfig[6]['module_path'] == 'tomopy.recon.algorithm'
-    assert LayerUI.PipelineStageConfig[6]['save_result'] == True
-    assert LayerUI.PipelineStageConfig[7]['method'] == 'median_filter'
-    assert LayerUI.PipelineStageConfig[7]['module_path'] == 'tomopy.misc.corr'
-    assert LayerUI.PipelineStageConfig[8]['method'] == 'save_to_images'
-    assert LayerUI.PipelineStageConfig[8]['module_path'] == 'httomolib.misc.images'
-
-
-def test_pipeline_cpu2_outref(standard_data, yaml_cpu_pipeline2):
-    """Testing OutputRef.
-    """
-    comm = MPI.COMM_WORLD
-    LayerUI = UiLayer(yaml_cpu_pipeline2, standard_data, comm=comm)
-
-    pipeline = LayerUI.build_pipeline()
-    
-    obj = LayerUI.PipelineStageConfig[6]['parameters']['center']
-    assert obj.mapped_output_name == 'centre_of_rotation' 
-
-
-def test_pipeline_cpu3_yaml(standard_data, yaml_cpu_pipeline3):
-    """Testing existing python pipelines by reading them and generating Pipelines
-    """
-
-    comm = MPI.COMM_WORLD
-    LayerUI = UiLayer(yaml_cpu_pipeline3, standard_data, comm=comm)
-
-    pipeline = LayerUI.build_pipeline()
-
-    assert len(pipeline._methods) == 7
-    assert LayerUI.PipelineStageConfig[0]['method'] == 'standard_tomo'
-    assert LayerUI.PipelineStageConfig[0]['module_path'] == 'httomo.data.hdf.loaders'
-    assert LayerUI.PipelineStageConfig[0]['parameters']['preview'] == [None, None, None]
-    assert LayerUI.PipelineStageConfig[1]['method'] == 'find_center_vo'
-    assert LayerUI.PipelineStageConfig[1]['module_path'] == 'tomopy.recon.rotation'    
-    assert LayerUI.PipelineStageConfig[1]['side_outputs'] == {'cor' : 'centre_of_rotation'}
-    assert LayerUI.PipelineStageConfig[2]['method'] == 'remove_outlier'
-    assert LayerUI.PipelineStageConfig[2]['module_path'] == 'tomopy.misc.corr'    
-    assert LayerUI.PipelineStageConfig[3]['method'] == 'normalize'
-    assert LayerUI.PipelineStageConfig[3]['module_path'] == 'tomopy.prep.normalize'
-    assert LayerUI.PipelineStageConfig[4]['method'] == 'minus_log'
-    assert LayerUI.PipelineStageConfig[4]['module_path'] == 'tomopy.prep.normalize'
-    assert LayerUI.PipelineStageConfig[5]['method'] == 'recon'
-    assert LayerUI.PipelineStageConfig[5]['module_path'] == 'tomopy.recon.algorithm'
-    assert LayerUI.PipelineStageConfig[6]['method'] == 'calculate_stats'
-    assert LayerUI.PipelineStageConfig[6]['module_path'] == 'httomo.methods'
-    assert LayerUI.PipelineStageConfig[6]['side_outputs'] == {'glob_stats' : 'glob_stats'}
-    assert LayerUI.PipelineStageConfig[7]['method'] == 'save_to_images'
-    assert LayerUI.PipelineStageConfig[7]['module_path'] == 'httomolib.misc.images'
-
-def test_pipeline_cpu3_outref(standard_data, yaml_cpu_pipeline3):
-    """Testing OutputRef.
-    """
-    comm = MPI.COMM_WORLD
-    LayerUI = UiLayer(yaml_cpu_pipeline3, standard_data, comm=comm)
-
-    pipeline = LayerUI.build_pipeline()
-    
-    obj = LayerUI.PipelineStageConfig[7]['parameters']['glob_stats']
-    assert obj.mapped_output_name == 'glob_stats'
-
-def test_pipeline_gpu1(standard_data, yaml_gpu_pipeline1):
-    """Testing existing python pipelines by reading them and generating Pipelines
-    """
-
-    comm = MPI.COMM_WORLD
-    LayerUI = UiLayer(yaml_gpu_pipeline1, standard_data, comm=comm)
-
-    pipeline = LayerUI.build_pipeline()
-
-    assert len(pipeline._methods) == 6
-    assert LayerUI.PipelineStageConfig[0]['method'] == 'standard_tomo'
-    assert LayerUI.PipelineStageConfig[0]['module_path'] == 'httomo.data.hdf.loaders'
-    assert LayerUI.PipelineStageConfig[0]['parameters']['preview'] == [None, None, None]
-    assert LayerUI.PipelineStageConfig[1]['method'] == 'find_center_vo'
-    assert LayerUI.PipelineStageConfig[1]['module_path'] == 'httomolibgpu.recon.rotation'    
-    assert LayerUI.PipelineStageConfig[1]['side_outputs'] == {'cor' : 'centre_of_rotation'}
-    assert LayerUI.PipelineStageConfig[2]['method'] == 'remove_outlier3d'
-    assert LayerUI.PipelineStageConfig[2]['module_path'] == 'httomolibgpu.misc.corr'    
-    assert LayerUI.PipelineStageConfig[3]['method'] == 'normalize'
-    assert LayerUI.PipelineStageConfig[3]['module_path'] == 'httomolibgpu.prep.normalize'
-    assert LayerUI.PipelineStageConfig[4]['method'] == 'remove_stripe_based_sorting'
-    assert LayerUI.PipelineStageConfig[4]['module_path'] == 'httomolibgpu.prep.stripe'
-    assert LayerUI.PipelineStageConfig[5]['method'] == 'FBP'
-    assert LayerUI.PipelineStageConfig[5]['module_path'] == 'httomolibgpu.recon.algorithm'
-    assert LayerUI.PipelineStageConfig[6]['method'] == 'save_to_images'
-    assert LayerUI.PipelineStageConfig[6]['module_path'] == 'httomolib.misc.images'
-
-
-def test_pipeline_gpu1_outref(standard_data, yaml_gpu_pipeline1):
-    """Testing OutputRef.
-    """
-    comm = MPI.COMM_WORLD
-    LayerUI = UiLayer(yaml_gpu_pipeline1, standard_data, comm=comm)
-
-    pipeline = LayerUI.build_pipeline()
-    
-    obj = LayerUI.PipelineStageConfig[5]['parameters']['center']
-    assert obj.mapped_output_name == 'centre_of_rotation'