Repository: DiamondLightSource/httomo (language: python)

Issue:
Enabling new global stats calculation method in httomo
We would like to create  a new method in `httomo` that deals with the statistics calculation by operating side_outputs, like 
```
- 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: 1
    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}}
```


Discussion / hints:

Fix patch:
diff --git a/01_diad_pipeline_gpu.yaml b/01_diad_pipeline_gpu.yaml
new file mode 100644
index 000000000..f9281dfbe
--- /dev/null
+++ b/01_diad_pipeline_gpu.yaml
@@ -0,0 +1,68 @@
+- httomo.data.hdf.loaders:
+    standard_tomo:
+      name: tomo
+      data_path: /entry/imaging/data
+      image_key_path: /entry/instrument/imaging/image_key
+      rotation_angles:
+        data_path: /entry/imaging_sum/gts_cs_theta
+      dimension: 1
+      preview:
+        - 
+        - start: 1000
+          stop: 1005
+        - start: 700
+          stop: 2000
+      pad: 0
+      ignore_darks:
+        batch:
+          - start: 20
+            stop: 39
+- httomolibgpu.prep.normalize:
+    normalize:
+      data_in: tomo
+      data_out: tomo
+      cutoff: 10.0
+      minus_log: true
+      nonnegativity: false 
+#- httomolibgpu.recon.rotation:
+#    find_center_vo:
+#      data_in: tomo
+#      data_out: cor
+#      ind: null
+#      smin: -50
+#      smax: 50
+#      srad: 6.0
+#      step: 0.25
+#      ratio: 0.5
+#      drop: 20      
+#- httomolibgpu.recon.algorithm:
+#    FBP:
+#      data_in: tomo
+#      data_out: tomo
+#      center: !SweepRange
+#       start: 555
+#       stop: 565
+#       step: 0.5
+#      objsize: null
+- httomolibgpu.prep.stripe:
+    remove_stripe_based_sorting:
+      data_in: tomo
+      data_out: tomo
+      size: 21
+      dim: 1
+- httomolibgpu.recon.algorithm:
+    FBP:
+      data_in: tomo
+      data_out: tomo
+      center: 560
+      objsize: null      
+- httomolib.misc.images:
+    save_to_images:
+      data_in: tomo
+      subfolder_name: images
+      axis: 0
+      file_format: tif
+      bits: 8
+      perc_range_min: 0.0
+      perc_range_max: 100.0
+      jpeg_quality: 95
diff --git a/httomo/_stats/globals.py b/httomo/_stats/globals.py
deleted file mode 100644
index 677326e88..000000000
--- a/httomo/_stats/globals.py
+++ /dev/null
@@ -1,35 +0,0 @@
-import numpy as np
-from mpi4py import MPI
-from mpi4py.MPI import Comm
-
-
-def min_max_mean_std(data: np.ndarray, comm: Comm):
-    """calculating global statistics of the given array    
-    Args:
-        data: (np.ndarray): a numpy array
-        comm: The MPI communicator to use.
-    Returns:
-        tuple[(float, float, float, float)]: (min, max, mean, std_var)
-    """
-    
-    data = np.nan_to_num(data, copy=False, nan=0.0, posinf=0, neginf=0)    
-    
-    #comm.Barrier()
-    
-    # max/min global values
-    maxval_glob = comm.allreduce(np.max(data.ravel()), op=MPI.MAX)
-    minval_glob = comm.allreduce(np.min(data.ravel()), op=MPI.MIN)
-        
-    # mean calculation
-    buf = np.zeros_like(data.ravel())
-    MPI.COMM_WORLD.Allreduce(data.ravel(), buf, op=MPI.SUM)
-    buf /= MPI.COMM_WORLD.Get_size()
-    cmean_glob = np.mean(buf)       
-   
-    #TODO: standard deviation, follow
-    # https://mpitutorial.com/tutorials/mpi-reduce-and-allreduce/
-    # rsum = comm.allreduce((abs(data - cmean_glob) ** 2).sum())
-    # sigma_glob = (rsum / csize) ** 0.5
-    sigma_glob = 0.0
-    
-    return minval_glob, maxval_glob, cmean_glob, sigma_glob
diff --git a/httomo/cli.py b/httomo/cli.py
index 1d1ddeffd..f581f5d93 100644
--- a/httomo/cli.py
+++ b/httomo/cli.py
@@ -2,46 +2,30 @@
 from datetime import datetime
 from pathlib import Path, PurePath
 from shutil import copy
-from typing import Optional
+from typing import Optional, Union
 
 import click
 from mpi4py import MPI
 
 import httomo.globals
 from httomo.logger import setup_logger
-from httomo.task_runner import run_tasks
 from httomo.yaml_checker import validate_yaml_config
-from httomo.yaml_loader import YamlLoader
+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: High Throughput Tomography."""
+    """httomo: Software for High Throughput Tomography in parallel beam.
+     
+      Use `python -m httomo run --help` for more help on the runner. 
+    """
     pass
 
-
-@main.command()
-@click.argument(
-    "yaml_config", type=click.Path(exists=True, dir_okay=False, path_type=Path)
-)
-@click.argument(
-    "in_data",
-    type=click.Path(exists=True, dir_okay=False, path_type=Path),
-    required=False,
-)
-def check(yaml_config: Path, in_data: Path = None):
-    """Check a YAML pipeline file for errors."""
-    in_data = str(in_data) if isinstance(in_data, PurePath) else None
-    YamlLoader.add_constructor("!Sweep", YamlLoader.sweep_manual)
-    YamlLoader.add_constructor("!SweepRange", YamlLoader.sweep_range)
-    return validate_yaml_config(yaml_config, YamlLoader, in_data)
-
-
 @main.command()
-@click.argument("in_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)
 )
@@ -50,23 +34,9 @@ def check(yaml_config: Path, in_data: Path = None):
     type=click.Path(exists=True, file_okay=False, writable=True, path_type=Path),
 )
 @click.option(
-    "-d",
-    "--dimension",
-    type=click.IntRange(1, 3),
-    default=1,
-    help="The dimension to slice through.",
-)
-@click.option(
-    "--pad",
-    type=click.INT,
-    default=0,
-    help="The number of slices to pad each block of data.",
-)
-@click.option(
-    "--ncore",
-    type=click.INT,
-    default=1,
-    help=" The number of the CPU cores per process.",
+    "--save-all",
+    is_flag=True,
+    help="Save intermediate datasets for all tasks in the pipeline.",
 )
 @click.option(
     "--gpu-id",
@@ -74,53 +44,31 @@ def check(yaml_config: Path, in_data: Path = None):
     default=-1,
     help="The GPU ID of the device to use.",
 )
-@click.option(
-    "--save-all",
-    is_flag=True,
-    help="Save intermediate datasets for all tasks in the pipeline.",
-)
-@click.option(
-    "--file-based-reslice",
-    default=None,
-    is_flag=True,
-    help="Reslice using intermediate files (default is in-memory).",
-)
 @click.option(
     "--reslice-dir",
     type=click.Path(exists=True, file_okay=False, writable=True, path_type=Path),
     default=None,
-    callback=lambda context, param, value: value
-    if value
-    else context.params["out_dir"],
-    help="Directory for reslice intermediate files (defaults to out_dir, only relevant if --reslice is also given)",
-)
-@click.option(
-    "--output-folder",
-    type=click.Path(exists=False, file_okay=False, writable=True, path_type=Path),
-    default=None,
-    help="Optionally define the name of the output folder created by HTTomo",
+    help="Enable reslicing through the disk by providing a folder path to store intermediate file)",
 )
 def run(
-    in_file: Path,
+    in_data_file: Path,
     yaml_config: Path,
     out_dir: Path,
-    dimension: int,
-    pad: int,
-    ncore: int,
     gpu_id: int,
     save_all: bool,
-    file_based_reslice: bool,
-    reslice_dir: Path,
-    output_folder: Path,
+    reslice_dir: Union[Path, None],
 ):
-    """Run a processing pipeline defined in YAML on input data."""
+    """Run a pipeline defined in YAML on input data."""
+    
+    # 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: 
+    # _check_yaml(yaml_config, in_data_file)
+
     # Define httomo.globals.run_out_dir in all MPI processes
-    if output_folder is None:
-        httomo.globals.run_out_dir = out_dir.joinpath(
+    httomo.globals.run_out_dir = out_dir.joinpath(
             f"{datetime.now().strftime('%d-%m-%Y_%H_%M_%S')}_output"
-        )
-    else:
-        httomo.globals.run_out_dir = out_dir.joinpath(output_folder)
+    )
     comm = MPI.COMM_WORLD
     if comm.rank == 0:
         # Setup global logger object
@@ -146,14 +94,16 @@ def run(
         httomo.globals.gpu_id = gpu_id
 
     except ImportError:
-        pass  # silently pass and run the CPU pipeline
-
-    return run_tasks(
-        in_file,
-        yaml_config,
-        dimension,
-        pad,
-        ncore,
-        save_all,
-        reslice_dir if file_based_reslice else None,
-    )
+        pass  # silently pass and run if the CPU pipeline is given
+
+    # instantiate UiLayer class for pipeline build
+    init_UiLayer = UiLayer(yaml_config, in_data_file, comm=comm)
+    pipeline = init_UiLayer.build_pipeline()
+    
+    # Run the pipeline using Taskrunner
+    runner = TaskRunner(pipeline, save_all, reslice_dir)
+    return runner.execute()
+
+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
diff --git a/httomo/data/hdf/_utils/chunk.py b/httomo/data/hdf/_utils/chunk.py
index 69f0a7ca4..b153d9dc5 100644
--- a/httomo/data/hdf/_utils/chunk.py
+++ b/httomo/data/hdf/_utils/chunk.py
@@ -1,4 +1,5 @@
 from typing import Tuple
+from pathlib import Path
 
 import h5py as h5
 from mpi4py import MPI
@@ -7,14 +8,16 @@
 
 
 def save_dataset(
-    out_folder: str,
+    out_folder: Path,
     file_name: str,
     data: ndarray,
-    loader_info: LoaderData,
+    angles: ndarray,
+    detector_x: int,
+    detector_y: int,
     slice_dim: int = 1,
     chunks: Tuple = (150, 150, 10),
     path: str = "/data",
-    reslice: bool = False,    
+    reslice: bool = False,
     comm: MPI.Comm = MPI.COMM_WORLD,
 ) -> None:
     """Save dataset in parallel.
@@ -27,8 +30,14 @@ def save_dataset(
         Name of file to save dataset in.
     data : ndarray
         Data to save to file.
+    angles : ndarray
+        Angles of the loaded dataset.
+    detector_x : int
+        det_x (horizontal) detector of the loaded dataset.
+    detector_y : int
+        det_y (vertical) detector of the loaded dataset.
     loader_info: Dict
-        Dictionary with information about the loaded data.        
+        Dictionary with information about the loaded data.
     slice_dim : int
         Where data has been parallelized (split into blocks, each of which is
         given to an MPI process), provide the dimension along which the data was
@@ -52,12 +61,13 @@ def save_dataset(
     with h5.File(
         f"{out_folder}/{file_name}", file_mode, driver="mpio", comm=comm
     ) as file:
-        dataset = file.create_dataset(path, shape, dtype, chunks=chunks)        
+        dataset = file.create_dataset(path, shape, dtype, chunks=chunks)
         save_data_parallel(dataset, data, slice_dim)
-        file.create_dataset("/angles", data = loader_info.angles)
-        file.create_dataset(file_name, data = [0,0])
-        g1 = file.create_group('data_dims')
-        g1.create_dataset('detector_x_y',data = [loader_info.detector_x, loader_info.detector_y])
+        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])
+
 
 def save_data_parallel(
     dataset: h5.Dataset,
@@ -93,8 +103,11 @@ def save_data_parallel(
         dataset[:, :, i0:i1] = data[...]
 
 
-def get_data_shape(data: ndarray, dim: int, comm: MPI.Comm = MPI.COMM_WORLD) -> Tuple:
-    """Gets the shape of a distributed dataset.
+def get_data_shape_and_offset(
+    data: ndarray, dim: int, comm: MPI.Comm = MPI.COMM_WORLD
+) -> Tuple[Tuple, Tuple]:
+    """Gets the shape of the distribtued dataset as well as the offset of the
+    local one within the full data shape.
 
     Parameters
     ----------
@@ -107,12 +120,36 @@ def get_data_shape(data: ndarray, dim: int, comm: MPI.Comm = MPI.COMM_WORLD) ->
 
     Returns
     -------
-    Tuple
-        The shape of the given distributed dataset.
+    Tuple, Tuple
+        The shape of the given distributed dataset and the global starting index of this dataset,
+        both as a 3-tuple of integers
     """
     shape = list(data.shape)
     lengths = comm.gather(shape[dim], 0)
     lengths = comm.bcast(lengths, 0)
     shape[dim] = sum(lengths)
+    global_index = [0, 0, 0]
+    global_index[dim] = sum(lengths[: comm.rank - 1])
+    global_index = tuple(global_index)
     shape = tuple(shape)
-    return shape
+    return shape, global_index
+
+
+def get_data_shape(data: ndarray, dim: int, comm: MPI.Comm = MPI.COMM_WORLD) -> Tuple:
+    """Gets the shape of a distributed dataset.
+
+    Parameters
+    ----------
+    data : ndarray
+        The process data.
+    dim : int
+        The dimension in which to get the shape.
+    comm : MPI.Comm
+        The MPI communicator.
+
+    Returns
+    -------
+    Tuple
+        The shape of the given distributed dataset.
+    """
+    return get_data_shape_and_offset(data, dim, comm)[0]
diff --git a/httomo/data/hdf/_utils/reslice.py b/httomo/data/hdf/_utils/reslice.py
index 39cf475cb..7f6e9936e 100644
--- a/httomo/data/hdf/_utils/reslice.py
+++ b/httomo/data/hdf/_utils/reslice.py
@@ -15,7 +15,7 @@ def reslice(
     current_slice_dim: int,
     next_slice_dim: int,
     comm: Comm,
-) -> Tuple[numpy.ndarray]:
+) -> Tuple[numpy.ndarray, int]:
     """Reslice data by using in-memory MPI directives.
 
     Parameters
@@ -75,6 +75,9 @@ def reslice_filebased(
     data: numpy.ndarray,
     current_slice_dim: int,
     next_slice_dim: int,
+    angles: numpy.ndarray,
+    detector_x: int,
+    detector_y: int,
     comm: Comm,
     reslice_dir: Path,
 ) -> Tuple[numpy.ndarray, int]:
@@ -91,6 +94,12 @@ def reslice_filebased(
     next_slice_dim : int
         The dimension along which the data should be sliced after re-chunking
         and saving.
+    angles : ndarray
+        Angles of the loaded dataset.
+    detector_x : int
+        det_x (horizontal) detector of the loaded dataset.
+    detector_y : int
+        det_y (vertical) detector of the loaded dataset.        
     comm : Comm
         The MPI communicator to be used.
     Returns:
@@ -127,6 +136,9 @@ def reslice_filebased(
         reslice_dir,
         "intermediate.h5",
         data,
+        angles,
+        detector_x,
+        detector_y,
         current_slice_dim,
         chunks_data,
         reslice=True,
@@ -139,7 +151,6 @@ def reslice_filebased(
 
     return data, next_slice_dim
 
-
 def single_sino_reslice(
     data: numpy.ndarray,
     idx: int,
diff --git a/httomo/data/hdf/_utils/save.py b/httomo/data/hdf/_utils/save.py
index 341ba64e8..e277e55e8 100644
--- a/httomo/data/hdf/_utils/save.py
+++ b/httomo/data/hdf/_utils/save.py
@@ -4,14 +4,15 @@
 from mpi4py.MPI import Comm
 
 from httomo.data.hdf._utils.chunk import save_dataset
-from httomo.data.hdf.loaders import LoaderData
 from httomo.utils import Colour, log_once
 
 
 def intermediate_dataset(
     data: numpy.ndarray,
     run_out_dir: Path,
-    loader_info: LoaderData,
+    angles: numpy.ndarray,
+    detector_x: int,
+    detector_y: int,
     comm: Comm,
     task_no: int,
     package_name: str,
@@ -28,8 +29,12 @@ def intermediate_dataset(
         The data to be written.
     run_out_dir : Path
         The directory to write the file to.
-    loader_info: Dict
-        Dictionary with information about the loaded data.
+    angles : numpy.ndarray
+        Projection angles array.
+    detector_x : int
+        Horizontal detector dimension.
+    detector_y : int
+        Vertical detector dimension.
     comm : Comm
         The MPI communicator to use.
     task_no : int
@@ -61,4 +66,14 @@ def intermediate_dataset(
     log_once(
         f"Saving intermediate file: {filename}", comm, colour=Colour.LYELLOW, level=1
     )
-    save_dataset(run_out_dir, filename, data, loader_info, slice_dim, chunks_recon, comm=comm)
+    save_dataset(
+        run_out_dir,
+        filename,
+        data,
+        angles,
+        detector_x,
+        detector_y,
+        slice_dim,
+        chunks_recon,
+        comm=comm,
+    )
diff --git a/httomo/data/hdf/loaders.py b/httomo/data/hdf/loaders.py
index a37e5f7c2..0168161c5 100644
--- a/httomo/data/hdf/loaders.py
+++ b/httomo/data/hdf/loaders.py
@@ -11,6 +11,10 @@
 from httomo.utils import Colour, _parse_preview, log_once, log_rank
 
 
+__all__ = [
+    "standard_tomo",
+]
+
 @dataclass
 class LoaderData:
     data: ndarray
@@ -24,7 +28,7 @@ class LoaderData:
 
 def standard_tomo(
     name: str,
-    in_file: os.PathLike | str,
+    in_file: Union[os.PathLike, str],
     data_path: str,
     dimension: int,
     preview: List[Dict[str, int]],
diff --git a/httomo/globals.py b/httomo/globals.py
index 905ba6f2a..124b463e5 100644
--- a/httomo/globals.py
+++ b/httomo/globals.py
@@ -1,3 +1,8 @@
-run_out_dir = None
-logger = None
-gpu_id = -1
+from logging import Logger
+import os
+from pathlib import Path
+from typing import Optional
+
+run_out_dir: os.PathLike = Path('.')
+logger: Optional[Logger] = None
+gpu_id: int = -1
diff --git a/httomo/logger.py b/httomo/logger.py
index 3306b12a8..f98daf75e 100644
--- a/httomo/logger.py
+++ b/httomo/logger.py
@@ -1,15 +1,18 @@
 import logging
 from pathlib import Path
-
-import httomo.globals
-
-
-def setup_logger(out_dir: str):
+import os
+from typing import Optional
+
+def setup_logger(out_dir: Optional[os.PathLike]):
+    if out_dir is None:
+        raise ValueError("out_dir has not been set")
+    out_path = Path(out_dir)
+    
     # Create timestamped output directory
-    Path.mkdir(httomo.globals.run_out_dir)
+    Path.mkdir(out_path)
 
     # Create empty `user.log` file
-    user_log_path = httomo.globals.run_out_dir / "user.log"
+    user_log_path = out_path / "user.log"
     Path.touch(user_log_path)
 
     #: set up logging to a user.log file
@@ -17,7 +20,7 @@ def setup_logger(out_dir: str):
         level=logging.INFO,
         format="%(asctime)s | %(levelname)s | %(message)s",
         datefmt="%d/%m/%Y %I:%M:%S %p",
-        filename=f"{httomo.globals.run_out_dir}/user.log",
+        filename=f"{out_path}/user.log",
         filemode="w",
     )
 
@@ -31,4 +34,6 @@ def setup_logger(out_dir: str):
 
     user_logger = logging.getLogger(__file__)
     user_logger.setLevel(logging.DEBUG)
+    
+    logging.getLogger('httomo.runner').setLevel(logging.INFO)
     return user_logger
diff --git a/httomo/methods.py b/httomo/methods.py
new file mode 100644
index 000000000..9f67896c2
--- /dev/null
+++ b/httomo/methods.py
@@ -0,0 +1,34 @@
+from typing import Tuple
+import numpy as np
+from mpi4py import MPI
+from mpi4py.MPI import Comm
+
+__all__ = [
+    "calculate_stats",
+]
+
+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)
+    """
+    
+    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)
diff --git a/httomo/methods_database/packages/httomo.yaml b/httomo/methods_database/packages/httomo.yaml
index 8425bfe88..f546bb6bb 100644
--- a/httomo/methods_database/packages/httomo.yaml
+++ b/httomo/methods_database/packages/httomo.yaml
@@ -5,4 +5,10 @@ data:
         pattern: projection
         output_dims_change: False
         implementation: cpu
-        memory_gpu: None
+        memory_gpu: None 
+methods:  
+  calculate_stats:
+    pattern: all
+    output_dims_change: False
+    implementation: cpu
+    memory_gpu: None
diff --git a/httomo/methods_database/query.py b/httomo/methods_database/query.py
index f7e4f7aaa..16e12b346 100644
--- a/httomo/methods_database/query.py
+++ b/httomo/methods_database/query.py
@@ -1,9 +1,13 @@
-from typing import List, Union
+from types import ModuleType
+from typing import Callable, List, Literal, Tuple
 from pathlib import Path
+import numpy as np
 
 import yaml
+from httomo.runner.methods_repository_interface import GpuMemoryRequirement, MethodQuery
 
-from httomo.utils import log_exception
+from httomo.utils import Pattern, log_exception
+from httomo.runner.methods_repository_interface import MethodRepository
 
 YAML_DIR = Path(__file__).parent / "packages/"
 
@@ -73,3 +77,95 @@ def get_method_info(module_path: str, method_name: str, attr: str):
         return info[attr]
     except KeyError:
         raise KeyError(f"The attribute {attr} is not present on {method_path}")
+
+
+# Implementation of methods database query class
+class MethodsDatabaseQuery(MethodQuery):
+    def __init__(self, module_path: str, method_name: str):
+        self.module_path = module_path
+        self.method_name = method_name
+
+    def get_pattern(self) -> Pattern:
+        p = get_method_info(self.module_path, self.method_name, "pattern")
+        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."
+        )
+
+    def get_output_dims_change(self) -> bool:
+        p = get_method_info(self.module_path, self.method_name, "output_dims_change")
+        return bool(p)
+
+    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"
+            )
+        return p
+
+    def get_memory_gpu_params(
+        self,
+    ) -> List[GpuMemoryRequirement]:
+        p = get_method_info(self.module_path, self.method_name, "memory_gpu")
+        if p is None or p == "None":
+            return []
+        if type(p) == list:
+            # convert to dict first
+            dd = dict()
+            for item in p:
+                dd |= item
+        else:
+            dd = p
+        # now iterate and make it into one
+        assert (
+            len(dd["datasets"]) == len(dd["multipliers"]) == len(dd["methods"])
+        ), "Invalid data"
+        return [
+            GpuMemoryRequirement(
+                dataset=d, multiplier=dd["multipliers"][i], method=dd["methods"][i]
+            )
+            for i, d in enumerate(dd["datasets"])
+        ]
+
+    def calculate_memory_bytes(
+        self, non_slice_dims_shape: Tuple[int, int], dtype: np.dtype, **kwargs
+    ) -> Tuple[int, int]:
+        smodule = self._import_supporting_funcs_module()
+        module_mem: Callable = getattr(
+            smodule, "_calc_memory_bytes_" + self.method_name
+        )
+        memory_bytes: Tuple[int, int] = module_mem(
+            non_slice_dims_shape, dtype, **kwargs
+        )
+        return memory_bytes
+
+    def calculate_output_dims(
+        self, non_slice_dims_shape: Tuple[int, int], **kwargs
+    ) -> Tuple[int, int]:
+        smodule = self._import_supporting_funcs_module()
+        module_mem: Callable = getattr(smodule, "_calc_output_dim_" + self.method_name)
+        return module_mem(non_slice_dims_shape, **kwargs)
+
+    def _import_supporting_funcs_module(self) -> ModuleType:
+        from importlib import import_module
+
+        module_mem_path = "httomo.methods_database.packages.external."
+        path = self.module_path.split(".")
+        path.insert(1, "supporting_funcs")
+        module_mem_path += ".".join(path)
+        return import_module(module_mem_path)
+
+    def swap_dims_on_output(self) -> bool:
+        return self.module_path.startswith("tomopy.recon")
+
+
+class MethodDatabaseRepository(MethodRepository):
+    def query(self, module_path: str, method_name: str) -> MethodQuery:
+        return MethodsDatabaseQuery(module_path, method_name)
diff --git a/httomo/pipeline_reader.py b/httomo/pipeline_reader.py
deleted file mode 100644
index 9d6ede78e..000000000
--- a/httomo/pipeline_reader.py
+++ /dev/null
@@ -1,160 +0,0 @@
-import yaml
-from pathlib import Path
-from typing import Any, Dict, List, Protocol, TypeAlias
-from importlib import import_module
-
-from mpi4py import MPI
-
-from httomo.common import LoaderInfo, MethodFunc, PreProcessInfo
-from httomo.utils import Pattern, log_exception
-from httomo.wrappers_class import BackendWrapper
-from httomo.yaml_loader import YamlLoader
-
-
-MethodConfig: TypeAlias = Dict[str, Dict[str, Any]]
-PipelineStageConfig: TypeAlias = List[MethodConfig]
-PipelineConfig: TypeAlias = List[PipelineStageConfig]
-
-
-class PipelineReaderInterface(Protocol):
-    """
-    Functionalities that a pipeline YAML file reader should implement.
-    """
-    loader: type[YamlLoader]
-
-    def get_loader_info(self, filepath: Path, extra_params: Dict[str, Any]) -> LoaderInfo:
-        ...
-
-    def get_pre_process_info(self, filepath: Path, comm: MPI.Comm) -> List[PreProcessInfo]:
-        ...
-
-    def get_main_pipeline_info(self, filepath: Path, comm: MPI.Comm) -> List[MethodFunc]:
-        ...
-
-
-class PipelineReader:
-    """
-    Generate necessary objects for executing the loading, pre-processing, and
-    main processing stages of the pipeline.
-    """
-
-    def __init__(self, loader: type[YamlLoader]):
-        loader.add_constructor("!Sweep", loader.sweep_manual)
-        loader.add_constructor("!SweepRange", loader.sweep_range)
-        self.loader = loader
-
-
-    def get_loader_info(self, filepath: Path, extra_params: Dict[str, Any]) -> LoaderInfo:
-        loader_conf: MethodConfig = self.__parse_yaml(filepath)[0][0]
-        module_name, module_conf = loader_conf.popitem()
-        method_name, method_conf = module_conf.popitem()
-        module = import_module(module_name)
-        method_func = getattr(module, method_name)
-        method_conf.update(extra_params)
-        if "preview" not in method_conf.keys():
-            method_conf["preview"] = [None]
-        return LoaderInfo(
-            params=method_conf,
-            method_name=method_name,
-            method_func=method_func,
-            pattern=Pattern(extra_params["dimension"] - 1)
-        )
-
-
-    def get_pre_process_info(
-            self,
-            filepath: Path,
-            comm: MPI.Comm
-        ) -> List[PreProcessInfo]:
-        pre_process_conf: PipelineStageConfig = self.__parse_yaml(filepath)[1]
-        pre_process_infos: List[PreProcessInfo] = []
-        PRE_PROCESS_METHODS = [
-            "remove_outlier3d",
-            "find_center_vo",
-            "find_center_360"
-        ]
-
-        for task_conf in pre_process_conf:
-            module_name, module_conf = task_conf.popitem()
-            split_module_name = module_name.split(".")
-            method_name, method_conf = module_conf.popitem()
-
-            if method_name not in PRE_PROCESS_METHODS:
-                err_str = f"Method {method_name} not allowed in pre-processing stage"
-                raise ValueError(err_str)
-
-            wrapper_init_module = BackendWrapper(
-                split_module_name[0],
-                split_module_name[1],
-                split_module_name[2],
-                method_name,
-                comm
-            )
-
-            pre_process_infos.append(PreProcessInfo(
-                params=method_conf,
-                method_name=method_name,
-                module_path=module_name,
-                wrapper_func=wrapper_init_module.wrapper_method
-            ))
-
-        return pre_process_infos
-
-
-    def get_main_pipeline_info(
-            self,
-            filepath: Path,
-            comm: MPI.Comm
-        ) -> List[MethodFunc]:
-        conf: PipelineStageConfig = self.__parse_yaml(filepath)[2]
-        method_funcs: List[MethodFunc] = []
-
-        for i, task_conf in enumerate(conf):
-            module_name, module_conf = task_conf.popitem()
-            split_module_name = module_name.split(".")
-            method_name, method_conf = module_conf.popitem()
-            method_conf["method_name"] = method_name
-
-            if split_module_name[0] not in ["tomopy", "httomolib", "httomolibgpu"]:
-                err_str = (
-                    f"An unknown module name was encountered: " f"{split_module_name[0]}"
-                )
-                log_exception(err_str)
-                raise ValueError(err_str)
-
-            wrapper_init_module = BackendWrapper(
-                split_module_name[0],
-                split_module_name[1],
-                split_module_name[2],
-                method_name,
-                comm
-            )
-            wrapper_func = getattr(wrapper_init_module.module, method_name)
-            wrapper_method = wrapper_init_module.wrapper_method
-
-            method_funcs.append(
-                MethodFunc(
-                    module_name=module_name,
-                    method_func=wrapper_func,
-                    wrapper_func=wrapper_method,
-                    parameters=method_conf,
-                    cpu=True,
-                    gpu=False,
-                    cupyrun=False,
-                    calc_max_slices=None,
-                    output_dims_change=False,
-                    pattern=Pattern.all,
-                    return_numpy=False,
-                    idx_global=i+2,
-                    global_statistics=False,
-                )
-            )
-
-        return method_funcs
-
-
-    def __parse_yaml(self, filepath: Path) -> PipelineConfig:
-        with open(filepath, "r") as f:
-            yaml_conf = list(yaml.load_all(f, Loader=self.loader))
-        return yaml_conf
-
diff --git a/httomo/postrun.py b/httomo/postrun.py
deleted file mode 100644
index 6806d7b8c..000000000
--- a/httomo/postrun.py
+++ /dev/null
@@ -1,54 +0,0 @@
-"""
-Module containing postrun functionality for the methods in the HTTomo pipeline.
-This includes saving the results - hdf5 datasets, tiff images, etc.
-"""
-from typing import Any, Dict, Optional
-
-import numpy as np
-from httomolib.misc.images import save_to_images
-from mpi4py import MPI
-
-import httomo.globals
-from httomo.common import MethodFunc, RunMethodInfo, PlatformSection
-from httomo.data.hdf.loaders import LoaderData
-from httomo.data.hdf._utils.chunk import get_data_shape, save_dataset
-from httomo.data.hdf._utils.save import intermediate_dataset
-from httomo.utils import _get_slicing_dim
-
-comm = MPI.COMM_WORLD
-
-
-def postrun_method(
-    run_method_info: RunMethodInfo,
-    dict_datasets_pipeline: Dict[str, Optional[np.ndarray]],
-    section: PlatformSection,
-    loader_info: LoaderData,
-):
-    if run_method_info.method_name != "save_to_images" and 'center' not in run_method_info.method_name:
-    
-        is_3d = len(dict_datasets_pipeline[run_method_info.data_out].shape) == 3
-
-        # Save the result into intermediate file if necessary
-        if run_method_info.save_result and is_3d:
-            recon_center = run_method_info.dict_params_method.pop("center", None)
-            recon_algorithm = None
-            if recon_center is not None:
-                # get the name of the reconstruction algorithm
-                recon_algorithm = run_method_info.dict_params_method.pop(
-                    "algorithm", None
-                )
-
-            slice_dim = _get_slicing_dim(section.pattern)
-
-            intermediate_dataset(
-                dict_datasets_pipeline[run_method_info.data_out],
-                httomo.globals.run_out_dir,
-                loader_info,
-                comm,
-                run_method_info.task_idx_global,
-                run_method_info.package_name,
-                run_method_info.method_name,
-                run_method_info.data_out,
-                slice_dim,
-                recon_algorithm=recon_algorithm,                
-            )
diff --git a/httomo/preprocess.py b/httomo/preprocess.py
deleted file mode 100644
index c503165c8..000000000
--- a/httomo/preprocess.py
+++ /dev/null
@@ -1,122 +0,0 @@
-"""
-Module contains pre-processing functions that are needed before the main 
-loop over GPU blocks has started. 
-
-For example: Centering, Dezingering for multidata, etc. 
-"""
-from typing import Any, Tuple, TypeAlias, Union
-
-import numpy as np
-from mpi4py import MPI
-from numpy import newaxis
-
-from httomo.common import PreProcessInfo
-from httomo.data.hdf._utils.reslice import single_sino_reslice
-from httomo.methods_database.query import get_method_info
-
-
-Centering180Result: TypeAlias = float
-Centering360Result: TypeAlias = Tuple[float, float, float]
-
-
-def dezinging(
-    data: np.ndarray[Any, np.dtype[np.float32]],
-    preproc_info: PreProcessInfo,
-) -> np.ndarray[Any, np.dtype[np.float32]]:
-
-    param_filter = ("method_name")
-    is_cupyrun = get_method_info(
-        preproc_info.module_path,
-        preproc_info.method_name,
-        "implementation"
-    ) == "gpu_cupy"
-
-    return preproc_info.wrapper_func(
-        preproc_info.method_name,
-        {k:preproc_info.params[k] for k in preproc_info.params.keys() - param_filter},
-        data,
-        return_numpy=True,
-        cupyrun=is_cupyrun
-    )
-
-
-def centering(
-    projs: np.ndarray[Any, np.dtype[np.float32]],
-    darks: np.ndarray[Any, np.dtype[np.float32]],
-    flats: np.ndarray[Any, np.dtype[np.float32]],
-    centering_method_info: PreProcessInfo,
-    comm: MPI.Comm,
-) -> Union[Centering180Result, Centering360Result]:
-    """_summary_
-
-    Args:
-        projs: np.ndarray: projection data
-        darks: np.ndarray: dark-field data
-        flats: np.ndarray: flat-field data
-        centering_method_info PreProcessInfo: object representing centering method
-        comm (MPI.Comm): communicator object
-
-    Returns:
-        Union[float, Tuple[float, float, float, float]]: result from regular
-            centering, or 360 centering
-    """
-    sino_slice, slice_for_cor = _get_sino(projs, centering_method_info.params['ind'])
-
-    if comm.rank == 0:
-        sino_slice = _normalise_sino(
-            sino_slice,
-            flats[:,slice_for_cor,:],
-            darks[:,slice_for_cor,:],
-        )
-
-        param_filter = ("method_name", "data_in", "data_out", "ind")
-        is_cupyrun = get_method_info(
-            centering_method_info.module_path,
-            centering_method_info.method_name,
-            "implementation"
-        ) == "gpu_cupy"
-        
-        res = centering_method_info.wrapper_func(
-            centering_method_info.method_name,
-            {k:centering_method_info.params[k] for k in centering_method_info.params.keys() - param_filter},
-            sino_slice,
-            return_numpy=False,
-            cupyrun=is_cupyrun
-        )
-    else:
-        res = None
-
-    res: Union[Centering180Result, Centering360Result] = comm.bcast(res, root=0)
-    return res
-
-
-def _get_sino(
-    data: np.ndarray[Any, np.dtype[np.float32]],
-    idx: str,
-) -> Tuple[np.ndarray, int]:
-    if idx == "mid" or idx is None:
-        # local middle value to the preview index
-        slice_for_cor = data.shape[1] // 2 - 1
-
-    # Gather a single sinogram to the rank 0 process
-    sino_slice = single_sino_reslice(data, slice_for_cor)
-    return sino_slice, slice_for_cor
-
-
-def _normalise_sino(
-    sino: np.ndarray[Any, np.dtype[np.float32]],
-    flats: np.ndarray[Any, np.dtype[np.float32]],
-    darks: np.ndarray[Any, np.dtype[np.float32]],
-) -> np.ndarray[Any, np.dtype[np.float32]]:
-    if flats is not None:
-        flats1d = np.float32(np.mean(flats, 0))
-    else:
-        flats1d = 1.0
-    if darks is not None:
-        darks1d = np.float32(np.mean(darks))
-    else:
-        darks1d = 0.0
-    denom = flats1d - darks1d
-    denom[(np.where(denom == 0.0))] = 1.0
-    sino -= darks1d / denom
-    return sino[:, newaxis, :] # increase dim
diff --git a/httomo/prerun.py b/httomo/prerun.py
deleted file mode 100644
index 1ada9198c..000000000
--- a/httomo/prerun.py
+++ /dev/null
@@ -1,162 +0,0 @@
-"""
-Module containing prerun functionality for the methods in the HTTomo pipeline.
-This includes updating the dictionaries containing the method info.
-"""
-from collections.abc import Callable
-from inspect import signature
-from typing import Any, Dict, List, Tuple
-
-from mpi4py import MPI
-from numpy import ndarray
-
-from httomo.common import MethodFunc, RunMethodInfo, PlatformSection
-from httomo.utils import get_data_in_data_out
-
-comm = MPI.COMM_WORLD
-
-
-def prerun_method(
-    run_method_info: RunMethodInfo,
-    section: PlatformSection,
-    possible_extra_params: List[Tuple[List[str], object]],
-    current_func: MethodFunc,
-    dict_datasets_pipeline: Dict[str, ndarray],
-    save_all: bool,
-):
-
-    run_method_info.package_name = current_func.module_name.split(".")[0]
-    dict_params_method = current_func.parameters
-    run_method_info.method_name = current_func.method_func.__name__
-    func_wrapper = current_func.wrapper_func
-
-    # extra params unrelated to wrapped packages but related to httomo added
-    run_method_info.dict_httomo_params = _check_signature_for_httomo_params(
-        func_wrapper, current_func, possible_extra_params
-    )
-    # assign a global index of the method
-    run_method_info.task_idx_global = section.methods[run_method_info.task_idx].idx_global
-    
-    # check platform section object to decide when numpy array needs to be returned   
-    if section.methods[run_method_info.task_idx].return_numpy:
-        run_method_info.dict_httomo_params['return_numpy'] = True
-    
-    # if the method uses CuPy API
-    if section.methods[run_method_info.task_idx].cupyrun:
-        run_method_info.dict_httomo_params['cupyrun'] = True
-    
-    # Get the information describing if the method is being run only
-    # once, or multiple times with different input datasets
-    #
-    # Make the input and output datasets always be lists just to be
-    # generic, and further down loop through all the datasets that the
-    # method should be applied to
-    data_in, data_out = get_data_in_data_out(
-        run_method_info.method_name, dict_params_method
-    )
-
-    # Check if the method function's params require any datasets stored
-    # in the `datasets` dict
-    dataset_params = _check_method_params_for_datasets(
-        dict_params_method, dict_datasets_pipeline
-    )
-    # Update the relevant parameter values according to the required
-    # datasets
-    dict_params_method.update(dataset_params)
-
-    # check if save_result is in the dict_params_method
-    try:
-        save_res = dict_params_method['save_result']
-        dict_params_method.pop('save_result') # remove the item
-    except:
-        save_res = False
-               
-    if save_res or save_all:
-        run_method_info.save_result = True
-        run_method_info.dict_httomo_params['return_numpy'] = True
-
-    # check if global stats is requested in dict_params_method
-    try:
-        glob_stats = dict_params_method['glob_stats']        
-    except:
-        glob_stats = False
-    
-    if glob_stats:
-        run_method_info.global_statistics = True
-        
-    # check if the module needs the gpu_id parameter and flag it to add in the
-    # wrapper
-    gpu_id_par = "gpu_id" in signature(current_func.method_func).parameters
-    if gpu_id_par:
-        dict_params_method.update({"gpu_id": gpu_id_par})
-
-    run_method_info.dict_params_method = dict_params_method
-    run_method_info.data_in = data_in
-    run_method_info.data_out = data_out
-
-
-def _check_signature_for_httomo_params(
-    func_wrapper: Callable,
-    method_name: str,
-    possible_extra_params: List[Tuple[List[str], object]],
-) -> Dict:
-    """Check if the given method requires any parameters related to HTTomo.
-
-    Parameters
-    ----------
-    func_wrapper : Callable
-        Function of a wrapper whose type signature is to be inspected
-    method_name : str
-        The name of the method to apply.
-
-    possible_extra_params : List[Tuple[List[str], object]]
-        Each tuples contains a parameter name and the associated value that
-        should be added if a method requires that parameter (note: multiple
-        parameter names can be given in case the parameter isn't consistently
-        named across tomopy functions, such as "angles" vs "angles_radians")
-
-    Returns
-    -------
-    Dict
-        A dict with the parameter names and values to be added for the given
-        method function
-    """
-    extra_params = {}
-    sig_params = signature(func_wrapper).parameters
-    for names, val in possible_extra_params:
-        for name in names:
-            if name in sig_params:
-                extra_params[name] = val
-
-    return extra_params
-
-
-def _check_method_params_for_datasets(
-    dict_params_method: Dict, datasets: Dict[str, ndarray]
-) -> Dict:
-    """Check a given method function's parameter values to see if any of them
-    are a dataset.
-
-    Parameters
-    ----------
-    dict_params_method : Dict
-        The dict of param names and their values for a given method function.
-
-    datasets: Dict[str, ndarray]
-        The dict of dataset names and their associated arrays.
-
-    Returns
-    -------
-    Dict
-        A dict containing the parameter names assigned to particular datasets,
-        and the associated dataset values. This dict can be used to update the
-        method function's params dict to reflect the dataset arrays that the
-        method function requires.
-    """
-    dataset_params = {}
-    for name, val in dict_params_method.items():
-        # If the value of this parameter is a dataset, then replace the
-        # parameter value with the dataset value
-        if val in datasets:
-            dataset_params[name] = datasets[val]
-
-    return dataset_params
diff --git a/httomo/_stats/__init__.py b/httomo/runner/__init__.py
similarity index 100%
rename from httomo/_stats/__init__.py
rename to httomo/runner/__init__.py
diff --git a/httomo/runner/backend_wrapper.py b/httomo/runner/backend_wrapper.py
new file mode 100644
index 000000000..0913bbfd8
--- /dev/null
+++ b/httomo/runner/backend_wrapper.py
@@ -0,0 +1,731 @@
+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
new file mode 100644
index 000000000..010c506dc
--- /dev/null
+++ b/httomo/runner/block_split.py
@@ -0,0 +1,127 @@
+import math
+from typing import Iterator
+import numpy as np
+from httomo.runner.dataset import DataSet
+from httomo.utils import Pattern, _get_slicing_dim
+import logging
+
+log = logging.getLogger(__name__)
+
+class BlockSplitter:
+    """Can split a full DataSet object into blocks 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)
+         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
+        )
+
+    @property
+    def slices_per_block(self) -> int:
+        return self._max_slices
+
+    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 __iter__(self) -> Iterator[DataSet]:
+        class BlockIterator:
+            def __init__(self, splitter):
+                self.splitter = splitter
+                self._current = 0
+
+            def __next__(self) -> DataSet:
+                if self._current >= len(self.splitter):
+                    raise StopIteration
+                v = self.splitter[self._current]
+                self._current += 1
+                return v
+
+        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/dataset.py b/httomo/runner/dataset.py
new file mode 100644
index 000000000..1c6af6271
--- /dev/null
+++ b/httomo/runner/dataset.py
@@ -0,0 +1,340 @@
+from typing import Optional, Tuple, TypeAlias, Union
+from httomo.utils import gpu_enabled, xp
+import numpy as np
+
+
+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::
+
+       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
+    """
+
+    generic_array: TypeAlias = Union[xp.ndarray, np.ndarray]
+
+    # TODO: Think about whether detector_x, detector_y, angles_total should be here?
+
+    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),
+    ):
+        self._angles = angles
+        self._flats = flats
+        self._darks = darks
+        self._data = data
+
+        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
+
+    @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
+
+    @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)
+
+    @property
+    def chunk_shape(self) -> Tuple[int, int, int]:
+        """Shape of the full chunk handled by the current process"""
+        return self.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
+
+    @property
+    def angles(self) -> np.ndarray:
+        return self._get_value("angles")
+
+    @angles.setter
+    def angles(self, new_data: np.ndarray):
+        self._set_value("angles", new_data)
+
+    @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
+
+    @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)
+
+    @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
+
+    @property
+    def data(self) -> generic_array:
+        return self._data
+
+    @data.setter
+    def data(self, new_data: generic_array):
+        self._data = new_data
+
+    @property
+    def is_gpu(self) -> bool:
+        """Check if arrays are currently residing on GPU"""
+        return getattr(self._data, "device", None) is not None
+
+    @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
+
+    @property
+    def is_locked(self) -> bool:
+        return self._is_locked
+
+    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)
+
+    def to_cpu(self):
+        """Transfter dataset to CPU (if not already)"""
+        if not self.is_gpu:
+            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
+
+    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/gpu_utils.py b/httomo/runner/gpu_utils.py
new file mode 100644
index 000000000..69e104668
--- /dev/null
+++ b/httomo/runner/gpu_utils.py
@@ -0,0 +1,25 @@
+from httomo.utils import gpu_enabled, xp
+
+
+def gpumem_cleanup():
+    """cleans up GPU memory and also the FFT plan cache"""
+    if gpu_enabled:
+        xp.get_default_memory_pool().free_all_blocks()
+        cache = xp.fft.config.get_plan_cache()
+        cache.clear()
+    
+        
+def get_available_gpu_memory(safety_margin_percent: float = 10.0) -> int:
+    try:
+        import cupy as cp
+        from httomo.data.mpiutil import local_rank
+
+        dev = cp.cuda.Device(local_rank)
+        with dev:
+            gpumem_cleanup()
+            pool = cp.get_default_memory_pool()
+            available_memory = dev.mem_info[0] + pool.free_bytes()
+            return int(available_memory * (1 - safety_margin_percent / 100.0))
+    except:
+        return int(100e9)  # arbitrarily high number - only used if GPU isn't available
+
diff --git a/httomo/runner/loader.py b/httomo/runner/loader.py
new file mode 100644
index 000000000..c1e466861
--- /dev/null
+++ b/httomo/runner/loader.py
@@ -0,0 +1,129 @@
+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
+
+
+import os
+
+from httomo.runner.backend_wrapper import BackendWrapper
+
+
+class LoaderInterface(Protocol):
+    """Interface to a loader object"""
+
+    pattern: Pattern = Pattern.all
+    reslice: bool = False
+    method_name: str
+    package_name: str = 'httomo'
+
+    def load(self) -> DataSet:
+        ...  # pragma: no cover
+
+    def get_side_output(self) -> Dict[str, Any]:
+        ...  # pragma: no cover
+
+    @property
+    def detector_x(self) -> int:
+        ...  # pragma: no cover
+
+    @property
+    def detector_y(self) -> int:
+        ...  # 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,
+    )
diff --git a/httomo/runner/methods_repository_interface.py b/httomo/runner/methods_repository_interface.py
new file mode 100644
index 000000000..c6e896ba2
--- /dev/null
+++ b/httomo/runner/methods_repository_interface.py
@@ -0,0 +1,67 @@
+import numpy as np
+from dataclasses import dataclass
+from typing import List, Literal, Optional, Protocol, Tuple, Union
+
+from httomo.utils import Pattern
+
+
+@dataclass(frozen=True)
+class GpuMemoryRequirement:
+    dataset: Literal["data", "tomo", "darks", "flats"] = "tomo"
+    multiplier: Optional[float] = 1.0
+    method: Literal["direct", "module"] = "direct"
+
+
+class MethodQuery(Protocol):
+    """An interface to query information about a single method.
+    It is used by the backend wrapper classes to determine required information.
+
+    Note: Implementers might get the information from different places, such as
+    YAML files, decorators, etc, and it might also be hardcoded for specific ones.
+
+    """
+
+    def get_pattern(self) -> Pattern:
+        """Return the pattern of the method"""
+        ...  # pragma: no cover
+
+    def get_output_dims_change(self) -> bool:
+        """Check if output dimensions change"""
+        ...  # pragma: no cover
+
+    def get_implementation(self) -> Literal["cpu", "gpu", "gpu_cupy"]:
+        """Check for implementation of the method"""
+        ...  # pragma: no cover
+
+    def get_memory_gpu_params(self) -> List[GpuMemoryRequirement]:
+        """Get the parameters for the GPU memory estimation"""
+        ...  # 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.
+        (This is typically true for tomopy recon methods)"""
+        ...  # pragma: no cover
+
+    def calculate_memory_bytes(
+        self, non_slice_dims_shape: Tuple[int, int], dtype: np.dtype, **kwargs
+    ) -> Tuple[int, int]:
+        """Calculate the memory required in bytes, returning bytes method and subtract bytes tuple"""
+        ...  # pragma: no cover
+
+    def calculate_output_dims(
+        self, non_slice_dims_shape: Tuple[int, int], **kwargs
+    ) -> Tuple[int, int]:
+        """Calculate size of the non-slice dimensions for this method"""
+        ...  # pragma: no cover
+
+
+class MethodRepository(Protocol):
+    """Factory method class which can obtain a query object for each method.
+    It is representing a whole collection of methods and can create
+    queries for each of them from various sources.
+    """
+
+    def query(self, module_path: str, method_name: str) -> MethodQuery:
+        """Obtain a query object for a specifc method. Depending on the
+        method given, it may create different types of MethodQuery objects"""
+        ...  # pragma: no cover
diff --git a/httomo/runner/output_ref.py b/httomo/runner/output_ref.py
new file mode 100644
index 000000000..efa744255
--- /dev/null
+++ b/httomo/runner/output_ref.py
@@ -0,0 +1,25 @@
+from typing import Any, TYPE_CHECKING
+
+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
+
+
+class OutputRef:
+    """Class to reference an output from other methods lazily."""
+
+    def __init__(self, method: "BackendWrapper", mapped_output_name: str):
+        self.method = method
+        self.mapped_output_name = mapped_output_name
+
+    @property
+    def value(self) -> Any:
+        mapped_outputs = self.method.get_side_output()
+        if self.mapped_output_name not in mapped_outputs:
+            raise ValueError(
+                f"Output name {self.mapped_output_name} not found in"
+                + f" mapped outputs of method {self.method.method_name}."
+                + f" The following mapped outputs are known: {mapped_outputs.keys()}"
+            )
+        return mapped_outputs[self.mapped_output_name]
diff --git a/httomo/runner/pipeline.py b/httomo/runner/pipeline.py
new file mode 100644
index 000000000..2b119bc3b
--- /dev/null
+++ b/httomo/runner/pipeline.py
@@ -0,0 +1,57 @@
+from httomo.runner.loader import LoaderInterface
+from httomo.utils import Pattern
+from httomo.runner.backend_wrapper import BackendWrapper
+
+from typing import Iterator, List, Optional
+
+
+class Pipeline:
+    """Represents a pipeline of methods, stored by their wrappers, and the loader.
+    After creation, the pipeline is immutable."""
+
+    def __init__(
+        self,
+        loader: LoaderInterface,
+        methods: List[BackendWrapper],
+        save_results_set: List = [],
+        main_pipeline_start: int = 0,
+    ):
+        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]:
+        return iter(self._methods)
+
+    def __len__(self) -> int:
+        return len(self._methods)
+
+    @property
+    def loader_pattern(self) -> Pattern:
+        return self.loader.pattern
+
+    @loader_pattern.setter
+    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/platform_section.py
new file mode 100644
index 000000000..56e41e34b
--- /dev/null
+++ b/httomo/runner/platform_section.py
@@ -0,0 +1,151 @@
+from typing import Iterator, List
+
+import mpi4py
+from httomo.runner.pipeline import Pipeline
+from httomo.utils import Colour, Pattern, log_once
+from httomo.runner.backend_wrapper import BackendWrapper
+
+
+class PlatformSection:
+    """Represents on section of a pipeline that can be executed on the same platform,
+    and has the same dataset pattern."""
+
+    def __init__(
+        self,
+        gpu: bool,
+        pattern: Pattern,
+        reslice: bool,
+        max_slices: int,
+        methods: List[BackendWrapper],
+        save_result: bool = False
+    ):
+        self.gpu = gpu
+        self.pattern = pattern
+        self.reslice = reslice
+        self.max_slices = max_slices
+        self.methods = methods
+        self.save_result = save_result
+
+    def __iter__(self) -> Iterator[BackendWrapper]:
+        return iter(self.methods)
+
+    def __len__(self) -> int:
+        return len(self.methods)
+
+    def __getitem__(self, idx: int) -> BackendWrapper:
+        return self.methods[idx]
+
+
+def sectionize(pipeline: Pipeline, save_all: bool = False) -> List[PlatformSection]:
+    sections: List[PlatformSection] = []
+
+    # The functions below are internal to reduce duplication
+
+    def needs_global_input(method: BackendWrapper) -> bool:
+        return method.config_params.get("glob_stats", False)
+
+    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
+            if method.pattern != Pattern.all:
+                current_pattern = method.pattern
+            current_methods = [method]
+        else:
+            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)
+
+    _backpropagate_section_patterns(pipeline, sections)
+    _finalize_patterns(pipeline, sections)
+    _set_method_patterns(sections)
+
+    return sections
+
+
+def _backpropagate_section_patterns(
+    pipeline: Pipeline, sections: List[PlatformSection]
+):
+    """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
+    has.
+
+    Only special case: All methods have Pattern.all, which is handled separately
+    """
+    last_pattern = Pattern.all
+    for s in reversed(sections):
+        if s.pattern == Pattern.all:
+            s.pattern = last_pattern
+        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],
+    default_pattern=Pattern.projection,
+):
+    # final possible ambiguity: everything is Pattern.all -> pick projection by default
+    if len(sections) > 0 and sections[0].pattern == Pattern.all:
+        log_once(
+            "All pipeline sections support all patterns: choosing projection",
+            mpi4py.MPI.COMM_WORLD,
+            Colour.YELLOW,
+            level=2,
+        )
+        for s in sections:
+            s.pattern = default_pattern
+        pipeline.loader_pattern = default_pattern
+
+    assert all(s.pattern != Pattern.all for s in sections)
+    assert pipeline.loader_pattern != Pattern.all
+
+
+def _set_method_patterns(sections: List[PlatformSection]):
+    for s in sections:
+        for m in s:
+            m.pattern = s.pattern
\ No newline at end of file
diff --git a/httomo/runner/task_runner.py b/httomo/runner/task_runner.py
new file mode 100644
index 000000000..4a2c1090a
--- /dev/null
+++ b/httomo/runner/task_runner.py
@@ -0,0 +1,288 @@
+from itertools import islice
+import math
+import time
+from typing import Any, Dict, Optional, List, Tuple, 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.runner.gpu_utils import get_available_gpu_memory, gpumem_cleanup
+from httomo.runner.loader import LoaderInterface
+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
+
+
+log = logging.getLogger(__name__)
+
+
+class TaskRunner:
+    """Handles the execution of a pipeline"""
+
+    def __init__(
+        self,
+        pipeline: Pipeline,
+        save_all: bool = False,
+        reslice_dir: Optional[os.PathLike] = None,
+    ):
+        self.pipeline = pipeline
+        self.save_all = save_all
+        self.reslice_dir = reslice_dir
+        self.comm = MPI.COMM_WORLD
+
+        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.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()
+
+        sections = sectionize(self.pipeline, self.save_all)
+
+        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
+
+    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:
+            log_once(
+                f"WARNING: Reslicing is performed {self.reslice_count} times. The number of reslices increases the total run time.",
+                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]
+
+    def _execute_section(self, section: PlatformSection, section_index: int = 0):
+        assert self.dataset is not None, "Dataset has not been loaded yet"
+
+        slicing_dim_section = _get_slicing_dim(section.pattern) - 1
+        self.determine_max_slices(section, slicing_dim_section)
+
+        self._log_pipeline(
+            f"Maximum amount of slices is {section.max_slices} for section {section_index}",
+            level=1,
+        )
+
+        splitter = BlockSplitter(self.dataset, section.pattern, section.max_slices)
+        aggregator = BlockAggregator(self.dataset, section.pattern)
+        for block in splitter:
+            aggregator.append(self._execute_section_block(section, block))
+            gpumem_cleanup()
+
+        self.dataset = aggregator.full_dataset
+
+    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}"
+            )
+        return block
+
+    def _log_pipeline(self, str: str, level: int = 0, colour=Colour.BVIOLET):
+        log_once(str, comm=self.comm, colour=colour, level=level)
+
+    def _prepare(self):
+        self._log_pipeline(
+            f"See the full log file at: {httomo.globals.run_out_dir}/user.log",
+            colour=Colour.BVIOLET,
+        )
+        self._check_params_for_sweep()
+        self._load_datasets()
+
+    def _load_datasets(self):
+        start_time = self._log_task_start(
+            self.method_index,
+            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._log_task_end(
+            self.method_index,
+            start_time,
+            self.pipeline.loader.pattern,
+            self.pipeline.loader.method_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"""
+        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 _log_task_start(self, num: int, pattern: Pattern, name: str) -> int:
+        log_once(
+            f"Running task {num} (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'):
+        output_str_list = [
+            f"    Finished task {num} (pattern={pattern.name}): {name} (",
+            package,
+            f") Took {float(time.perf_counter_ns() - start_time)*1e-6:.2f}ms",
+        ]
+        log_once(output_str_list, comm=self.comm, colour=self.output_colour_list)
+
+    def _check_params_for_sweep(self):
+        # Check pipeline for the number of parameter sweeps present. If one is
+        # defined, raise an error, due to not supporting parameter sweeps in a
+        # "performance" run of httomo
+        params = [m.config_params for m in self.pipeline]
+        no_of_sweeps = sum(map(self._count_tuple_values, params))
+        if no_of_sweeps > 0:
+            err_str = (
+                f"There exists {no_of_sweeps} parameter sweep(s) in the "
+                "pipeline, but parameter sweeps are not supported in "
+                "`httomo performance`. Please either:\n  1) Remove the parameter "
+                "sweeps.\n  2) Use `httomo preview` to run this pipeline."
+            )
+            log_exception(err_str)
+            raise ValueError(err_str)
+
+    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
+
+        max_slices = data_shape[slicing_dim]
+        if not section.gpu:
+            section.max_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)
+
+        available_memory = get_available_gpu_memory(10.0)
+        available_memory_in_GB = round(available_memory / (1024**3), 2)
+        memory_str = (
+            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)
+        max_slices_methods = [max_slices] * len(section)
+
+        # loop over all methods in section
+        for idx, m in enumerate(section):
+            if len(m.memory_gpu) == 0:
+                max_slices_methods[idx] = max_slices
+                continue
+
+            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
+            )
+            max_slices_methods[idx] = min(max_slices, slices_estimated)
+            non_slice_dims_shape = output_dims
+
+        section.max_slices = min(max_slices_methods)
diff --git a/httomo/task_runner.py b/httomo/task_runner.py
deleted file mode 100644
index 80f37a2a1..000000000
--- a/httomo/task_runner.py
+++ /dev/null
@@ -1,833 +0,0 @@
-import dataclasses
-import multiprocessing
-import time
-import math
-import copy
-from collections.abc import Callable
-from dataclasses import dataclass, field
-from importlib import import_module
-from inspect import signature
-from pathlib import Path
-from typing import Any, Dict, List, Optional, Tuple, Union
-
-import numpy as np
-from httomolib.misc.images import save_to_images
-from mpi4py import MPI
-from numpy import ndarray
-
-from httomo.utils import gpu_enabled, xp
-from httomo.data.mpiutil import local_rank
-
-import httomo.globals
-import httomo.preprocess
-from httomo._stats.globals import min_max_mean_std
-from httomo.common import LoaderInfo, MethodFunc, PlatformSection, PreProcessInfo, ResliceInfo, RunMethodInfo
-from httomo.data.hdf._utils.chunk import get_data_shape, save_dataset
-from httomo.data.hdf._utils.reslice import reslice, reslice_filebased
-from httomo.data.hdf._utils.save import intermediate_dataset
-from httomo.data.hdf.loaders import LoaderData
-from httomo.methods_database.query import get_method_info
-from httomo.postrun import postrun_method
-from httomo.prerun import prerun_method
-from httomo.utils import (
-    Colour,
-    Pattern,
-    _get_slicing_dim,
-    log_exception,
-    log_once,
-    remove_ansi_escape_sequences,
-)
-from httomo.wrappers_class import _gpumem_cleanup
-from httomo.yaml_loader import YamlLoader
-from httomo.pipeline_reader import PipelineReader, PipelineReaderInterface
-
-
-def run_tasks(
-    in_file: Path,
-    yaml_config: Path,
-    dimension: int,
-    pad: int = 0,
-    ncore: int = 1,
-    save_all: bool = False,
-    reslice_dir: Optional[Path] = None,
-) -> None:
-    """Run the pipeline defined in the YAML config file
-
-    Parameters
-    ----------
-    in_file : Path
-        The file to read data from.
-    yaml_config : Path
-        The file containing the processing pipeline info as YAML.
-    dimension : int
-        The dimension to slice in.
-    pad : int
-        The padding size to use. Defaults to 0.
-    ncore : int
-        The number of the CPU cores per process.
-    save_all : bool
-        Specifies if intermediate datasets should be saved for all tasks in the
-        pipeline.
-    reslice_dir : Optional[Path]
-        Path where to store the reslice intermediate files, or None if reslicing
-        should be done in-memory.
-    """
-    comm = MPI.COMM_WORLD
-    if comm.size == 1:
-        # use all available CPU cores if not an MPI run
-        ncore = multiprocessing.cpu_count()
-
-    # Define dict to store arrays of the whole pipeline using provided YAML
-    # Define list to store dataset stats for each task in the user config YAML
-    glob_stats: List = []
-    dict_datasets_pipeline: Dict[str, np.ndarray] = {}
-
-    # Define dict of params that are needed by loader functions
-    dict_loader_extra_params = {
-        "in_file": in_file,
-        "dimension": dimension,
-        "pad": pad,
-        "comm": comm,
-    }
-
-    # Get objects representing the methods in the different stages defined in
-    # the pipeline YAML file
-    pipeline_reader: PipelineReaderInterface = PipelineReader(YamlLoader)
-    loader_run_info: LoaderInfo = pipeline_reader.get_loader_info(yaml_config, dict_loader_extra_params)
-    pre_process_infos: List[PreProcessInfo] = pipeline_reader.get_pre_process_info(yaml_config, comm)
-    method_funcs: List[MethodFunc] = pipeline_reader.get_main_pipeline_info(yaml_config, comm)
-
-    # store info about reslicing with ResliceInfo
-    reslice_info = ResliceInfo(
-        count=0, has_warn_printed=False, reslice_dir=reslice_dir
-    )
-    
-    # Associate patterns to method function objects and update dict_preprocess
-    for i, method_func in enumerate(method_funcs):
-        method_funcs[i] = _assign_attribute_to_method(method_func, "pattern")        
-
-    # Associate output_dims_change method attribute to method function objects
-    for i, method_func in enumerate(method_funcs):
-         method_funcs[i] = _assign_attribute_to_method(method_func, "output_dims_change")
-
-    # Associate implementation architectures to method function objects
-    for i, method_func in enumerate(method_funcs):
-         method_funcs[i] = _assign_attribute_to_method(method_func, "implementation")
-
-    # Associate memory attributes to method function objects
-    for i, method_func in enumerate(method_funcs):
-         method_funcs[i] = _assign_attribute_to_method(method_func, "memory_gpu")
-
-    # Initialising platform sections (skipping loader)
-    platform_sections = _determine_platform_sections(method_funcs, save_all)
-
-    # Check pipeline for the number of parameter sweeps present. If one is
-    # defined, raise an error, due to not supporting parameter sweeps in a
-    # "performance" run of httomo
-    params = [m.parameters for m in method_funcs]
-    no_of_sweeps = sum(map(_check_params_for_sweep, params))
-
-    if no_of_sweeps > 0:
-        err_str = (
-            f"There exists {no_of_sweeps} parameter sweep(s) in the "
-            "pipeline, but parameter sweeps are not supported in "
-            "`httomo performance`. Please either:\n  1) Remove the parameter "
-            "sweeps.\n  2) Use `httomo preview` to run this pipeline."
-        )
-        log_exception(err_str)
-        raise ValueError(err_str)
-
-    # start MPI timer for rank 0
-    if comm.rank == 0:
-        start_time = MPI.Wtime()
-
-    #: add to the console and log file, the full path to the user.log file
-    log_once(
-        f"See the full log file at: {httomo.globals.run_out_dir}/user.log",
-        comm,
-        colour=Colour.CYAN,
-        level=0,
-    )
-    
-    output_colour_list = [Colour.GREEN, Colour.CYAN, Colour.GREEN]
-    output_colour_list_short = [Colour.GREEN, Colour.CYAN]
-    log_once(
-        f"Running task 1 (pattern={loader_run_info.pattern.name}): {loader_run_info.method_name}...",
-        comm,
-        colour=Colour.LIGHT_BLUE,
-        level=0,
-    )
-
-    loader_start_time = time.perf_counter_ns()
-    loader_func = loader_run_info.method_func
-    loader_info = loader_func(**loader_run_info.params)
-        
-    output_str_list = [
-        f"    Finished task 1 (pattern={loader_run_info.pattern.name}): {loader_run_info.method_name} (",
-        "httomo",
-        f") Took {float(time.perf_counter_ns() - loader_start_time)*1e-6:.2f}ms",
-    ]
-    log_once(output_str_list, comm=comm, colour=output_colour_list)
-
-    # Update `dict_datasets_pipeline` dict with the data that has been
-    # loaded by the loader
-    # NOTE: conversion to float32 to avoid creating an additional output array in the GPU loop
-    dict_datasets_pipeline[loader_run_info.params["name"]] = np.float32(loader_info.data)
-    dict_datasets_pipeline["flats"] = np.float32(loader_info.flats)
-    dict_datasets_pipeline["darks"] = np.float32(loader_info.darks)
-    # Clean up `loader_info.data`, `loader_info.darks`, and `loader_info.flats`,
-    # as they have been converted to float32 and the original uint16 versions
-    # are no longer needed
-    del loader_info.data
-    del loader_info.darks
-    del loader_info.flats
-
-    # Extra params relevant to httomo that a wrapper function might need
-    possible_extra_params = [
-        (["darks"], dict_datasets_pipeline["darks"]),
-        (["flats"], dict_datasets_pipeline["flats"]),
-        (["angles", "angles_radians"], loader_info.angles),
-        (["comm"], comm),
-        (["out_dir"], httomo.globals.run_out_dir),
-        (["return_numpy"], False),
-        (["cupyrun"], False),
-        (["save_result"], False),
-    ]  
-
-    dezinging_preproc_idx: Optional[int] = None
-    for i, preproc in enumerate(pre_process_infos):
-        if "center" in preproc.method_name:
-            centering_preproc_idx = i
-            centering_out_name = preproc.params["data_out"]
-
-        if "remove_outlier3d" in preproc.method_name:
-            dezinging_preproc_idx = i
-
-    if dezinging_preproc_idx is not None:
-        dezing_preproc_str = f"Running pre-processing: {pre_process_infos[dezinging_preproc_idx].method_name}"
-        log_once(dezing_preproc_str, comm=comm, colour=Colour.BLUE, level=0)
-        dict_datasets_pipeline[loader_run_info.params["name"]] = \
-            httomo.preprocess.dezinging(
-                dict_datasets_pipeline[loader_run_info.params["name"]],
-                pre_process_infos[dezinging_preproc_idx]
-            )
-        dict_datasets_pipeline["darks"] = \
-            httomo.preprocess.dezinging(
-                dict_datasets_pipeline["darks"],
-                pre_process_infos[dezinging_preproc_idx]
-            )
-        dict_datasets_pipeline["flats"] = \
-            httomo.preprocess.dezinging(
-                dict_datasets_pipeline["flats"],
-                pre_process_infos[dezinging_preproc_idx]
-            )
-
-    centering_preproc_str = f"Running pre-processing: {pre_process_infos[centering_preproc_idx].method_name}"
-    log_once(centering_preproc_str, comm=comm, colour=Colour.BLUE, level=0)
-    cor = httomo.preprocess.centering(
-        dict_datasets_pipeline[loader_run_info.params["name"]],
-        dict_datasets_pipeline["darks"],
-        dict_datasets_pipeline["flats"],
-        pre_process_infos[centering_preproc_idx],
-        comm
-    )
-
-    # Store the output(s) of the method in the appropriate
-    # dataset in the `dict_datasets_pipeline` dict
-    if isinstance(cor, (tuple, list)):
-        # The method produced multiple outputs
-        for val, dataset in zip(cor, centering_out_name):
-            dict_datasets_pipeline[dataset] = val
-    else:
-        # The method produced a single output
-        dict_datasets_pipeline[centering_out_name] = cor
-    
-    # data shape and dtype are useful when calculating max slices
-    data_shape = dict_datasets_pipeline[loader_run_info.params["name"]].shape
-    # data_dtype = loader_info.data.dtype
-    data_dtype = np.dtype(np.float32) # make the data type constant for the run
-    
-    ##---------- MAIN LOOP STARTS HERE ------------##
-    idx = 0
-    # initialise the CPU data array with the loaded data, we override it at the end of every section
-    data_full_section = dict_datasets_pipeline[loader_run_info.params["name"]]
-    for i, section in enumerate(platform_sections):  
-        # getting the slicing dimension of the section
-        slicing_dim_section = _get_slicing_dim(section.pattern) - 1        
-        
-        # determine max_slices for the whole section and return output dims and type
-        output_dims_upd, data_type_upd = _update_max_slices(section,
-                                                            slicing_dim_section,
-                                                            data_shape,
-                                                            data_dtype,
-                                                            dict_datasets_pipeline)
-        
-        maxslices_str = f"Maximum amount of slices is {section.max_slices} for section {i}"
-        log_once(maxslices_str, comm=comm, colour=Colour.BVIOLET, level=1)
-
-        # iterations_for_blocks determines the number of iterations needed
-        # to raster through the data in blocks that would fit into the GPU memory
-        iterations_for_blocks = math.ceil(data_shape[slicing_dim_section] / section.max_slices)
-        # Define a list to store the `RunMethodInfo` objects created for each
-        # method in the methods-loop, for reuse across iterations over blocks
-        run_method_info_objs = []
-        # Check if any methods in the section are a recon method, because recon
-        # methods change the shape of the input data and thus the processed
-        # blocks cannot be straightforwardly put into `data_full_section`.
-        # Instead, a new numpy array is created with the necessary shape to hold
-        # the full reconstructed volume, and it's this array that is updated as
-        # blocks are processed in the blocks-loop
-        contains_recon = any(['recon.algorithm' in method_func.module_name for method_func in section.methods])
-        if contains_recon:
-            recon_arr = np.empty(output_dims_upd, dtype=np.float32)
-       
-        ##---------- LOOP OVER _BLOCKS_ IN THE SECTION ------------##
-        indices_start = 0
-        indices_end = int(section.max_slices)
-        slc_indices = [slice(None)] * len(data_shape)             
-        for it_blocks in range(iterations_for_blocks):
-            # preparing indices for the slicing of the data in blocks
-            slc_indices[slicing_dim_section] = slice(int(indices_start), int(indices_end), 1)
-
-            ##---------- LOOP OVER _METHODS_ IN THE BLOCK ------------##
-            for m_ind, methodfunc_sect in enumerate(section.methods):
-                # preparing everything for the wrapper execution
-                module_path = methodfunc_sect.module_name
-                method_name = methodfunc_sect.method_func.__name__
-                func_wrapper = methodfunc_sect.wrapper_func
-                package_name = methodfunc_sect.module_name.split(".")[0]
-                
-                # log related stuff
-                pattern_str = f"(pattern={section.pattern.name})"
-                package_str = f"({package_name})"
-                task_no_str = f"Running task {idx+2}"
-                task_end_str = task_no_str.replace("Running", "Finished")
-               
-                if it_blocks == iterations_for_blocks-1:
-                    log_once(
-                    f"{task_no_str} {pattern_str}: {method_name}...",
-                    comm,
-                    colour=Colour.LIGHT_BLUE,
-                    level=0,
-                    )
-                # Only run the `prerun_method()` once for a method, when the
-                # first block is going to be processed. This is because the
-                # method's parameters will not have changed from the first time
-                # it has run, only its input data, which is taken care of
-                # outside the `prerun_method()` function.
-                if it_blocks == 0:
-                    #: create an object that would be passed along to prerun_method,
-                    #: run_method, and postrun_method
-                    run_method_info = RunMethodInfo(task_idx=m_ind)
-                    run_method_info_objs.append(run_method_info)
-
-                    #: prerun - before running the method, update the dictionaries
-                    prerun_method(
-                        run_method_info,
-                        section,
-                        possible_extra_params,
-                        methodfunc_sect,
-                        dict_datasets_pipeline,
-                        save_all,
-                    )
-
-                    # remove methods name from the parameters list of a method
-                    #
-                    # TODO: As this value is not being used anywhere, and the
-                    # `method_name` variable is being defined by other means,
-                    # the addition of the method name to the dict of method
-                    # parameters in `_get_method_funcs()` possibly could be
-                    # removed?
-                    run_method_info.dict_params_method.pop('method_name')
-                else:
-                    # Reuse the `RunMethodInfo` object that was defined for the
-                    # method when the 0th block in the section was processed
-                    run_method_info = run_method_info_objs[m_ind]
-
-                if m_ind == 0:
-                    # Assign the block of data on a CPU to `data` parameter of
-                    # the method; this should happen once in the beginning of
-                    # the loop over methods
-                    run_method_info.dict_httomo_params["data"] = data_full_section[tuple(slc_indices)]
-                else:
-                    # Initialise with result from the previous method                    
-                    run_method_info.dict_httomo_params["data"] = res                    
-                
-                # Override the `data` param in the special case of a centering
-                # method
-                if 'center' in method_name and it_blocks == 0:
-                    slice_ind_center = run_method_info.dict_params_method['ind']
-                    if slice_ind_center is None or 'mid':
-                        slice_ind_center = data_full_section.shape[1] // 2  # get the middle slice of the whole data chunk
-                    # copy the "ind" slice from the last section dataset (a chunk)
-                    # NOTE: even if there are some GPU filters before in the section, 
-                    # we still be using the data from the LAST section                    
-                    run_method_info.dict_httomo_params["data"] = data_full_section[:,slice_ind_center,:]
-  
-                # Calculate stats on the result of the last method (except centering)
-                # It is triggered by adding glob_stats: true parameter to the parameters list
-                if 'center' not in method_name and i < len(platform_sections) and run_method_info.global_statistics:
-                    run_method_info.dict_params_method['glob_stats'] = min_max_mean_std(data_full_section, comm)
-
-                # ------ RUNNING THE WRAPPER -------#
-                start = time.perf_counter_ns()
-                if 'center' in method_name:
-                    if it_blocks == 0:
-                        # we need to avoid overriding "res" with a scalar.                        
-                        res_param = func_wrapper(
-                            method_name,
-                            run_method_info.dict_params_method,
-                            **run_method_info.dict_httomo_params,
-                        )
-                        # Store the output(s) of the method in the appropriate
-                        # dataset in the `dict_datasets_pipeline` dict
-                        if isinstance(res_param, (tuple, list)):
-                            # The method produced multiple outputs
-                            for val, dataset in zip(res_param, run_method_info.data_out):
-                                dict_datasets_pipeline[dataset] = val
-                        else:
-                            # The method produced a single output
-                            dict_datasets_pipeline[run_method_info.data_out] = res_param                       
-                    # passing the data to the next method (or initialise res)
-                    if m_ind == 0:
-                        res = data_full_section[tuple(slc_indices)]
-                elif method_name == "save_to_images":
-                    # just executing the wrapper (no output)
-                    func_wrapper(
-                            method_name,
-                            run_method_info.dict_params_method,
-                            **run_method_info.dict_httomo_params,
-                        )
-                    # passing the data to the next method (or initialise res)
-                    if m_ind == 0:
-                        res = data_full_section[tuple(slc_indices)]
-                else:
-                    # overriding the result with an output of a method
-                    res = func_wrapper(
-                        method_name,
-                        run_method_info.dict_params_method,
-                        **run_method_info.dict_httomo_params,
-                    )
-                # ------ WRAPPER COMPLETED -------#
-                stop = time.perf_counter_ns()
-
-                section_block_method_str = f"Section {i} runs method {method_name} on a block {it_blocks} of {indices_end-indices_start} slices"
-                output_str_list_verbose = [
-                    f"{section_block_method_str} ",
-                    f" Complete in {float(stop-start)*1e-6:.2f}ms",
-                ]
-                log_once(output_str_list_verbose, comm=comm, colour=output_colour_list_short, level = 1)
-               
-                if it_blocks == iterations_for_blocks-1:
-                    output_str_list_once = [
-                        f"    {task_end_str} {pattern_str}: {method_name} ",
-                        f" {package_str}",
-                    ]              
-                    log_once(output_str_list_once, comm=comm, colour=output_colour_list_short)
-                    idx += 1
-                
-                if isinstance(res, (tuple, list)):
-                    err_str = (
-                        "Methods producing multiple outputs are not yet "
-                        "supported in the GPU loop"
-                    )
-                    raise ValueError(err_str)
-    
-            ##************* METHODS LOOP IS COMPLETE *************##
-            # Saving the processed block (the block is in the CPU memory)
-            if not contains_recon:
-                data_full_section[tuple(slc_indices)] = res
-            else:
-                if recon_arr.shape[0] != res.shape[0]:
-                    # TomoPy returns reconstruction in a different shape from httomolibgpu
-                    recon_arr[tuple(slc_indices)] = xp.swapaxes(res,0,1)
-                else:
-                    recon_arr[tuple(slc_indices)] = res
-
-            # re-initialise the slicing indices
-            indices_start = indices_end
-            # checking if we still within the slicing dimension size and take remaining portion
-            res_indices = (indices_start + int(section.max_slices)) - data_shape[slicing_dim_section] 
-            if res_indices > 0:
-                res_indices = int(section.max_slices - res_indices)
-                indices_end += res_indices
-            else:
-                indices_end += section.max_slices
-            
-            # delete allocated memory pointers to free up the memory
-            run_method_info.dict_httomo_params["data"] = None
-            # now flushing the GPU memory
-            _gpumem_cleanup()
-            
-        ##************* BLOCKS LOOP IS COMPLETE *************##
-        # If the completed section contained a recon method, then the array
-        # created to hold the differently-shaped output of this section (due to
-        # the recon within the section changing the shape of the input data)
-        # must be assigned to
-        # - the original dataset name (defined by the loader) in
-        # `dict_datasets_pipeline`
-        # - the `data_full_section` variable
-        if contains_recon:
-            dict_datasets_pipeline[loader_run_info.params["name"]] = \
-                recon_arr
-            data_full_section = recon_arr
-        
-        # saving intermediate datasets IF it has been asked for
-        postrun_method(
-            run_method_info,
-            dict_datasets_pipeline,
-            section,
-            loader_info
-        )
-
-        if section.reslice:
-            # we reslice only when the pattern of the section changes
-            next_section_in = platform_sections[i+1].methods[0].parameters["data_in"]
-            dict_datasets_pipeline[next_section_in] = _perform_reslice(
-                dict_datasets_pipeline[next_section_in],
-                section,
-                platform_sections[i+1],
-                reslice_info,
-                comm
-            )
-            # re-initialise the section input with the resliced data
-            data_full_section = dict_datasets_pipeline[next_section_in]
-        
-        # update input data dimensions and data type for the next section        
-        data_shape = np.shape(data_full_section)
-        data_dtype = data_type_upd
-
-    ##************* SECTIONS LOOP IS COMPLETE *************##
-    elapsed_time = 0.0
-    if comm.rank == 0:
-        elapsed_time = MPI.Wtime() - start_time
-        end_str = f"~~~ Pipeline finished ~~~ took {elapsed_time} sec to run!"
-        log_once(end_str, comm=comm, colour=Colour.BVIOLET)
-        #: remove ansi escape sequences from the log file
-        remove_ansi_escape_sequences(f"{httomo.globals.run_out_dir}/user.log")    
-
-
-def _check_params_for_sweep(params: Dict) -> int:
-    """Check the parameter dict of a method for the number of parameter sweeps
-    that occur.
-    """
-    count = 0
-    for k, v in params.items():
-        if type(v) is tuple:
-            count += 1
-    return count
-
-
-def _assign_attribute_to_method(
-    method_function: MethodFunc,
-    attribute: str,
-    ) -> MethodFunc:
-    """Fetch the information about a given attribute from the methods database in
-    `httomo/methods_database/packages` for the given method and associate that
-    with the function object.
-
-    Parameters
-    ----------
-    method_function : MethodFunc
-        The method function to populate.
-    attribute: str
-        A given attribute of a method, e.g. "pattern", "implementation", etc. 
-    Returns
-    -------
-    MethodFunc
-        The updated function object
-    """
-    if attribute in ["pattern", "implementation", "memory_gpu", "output_dims_change"]:
-        attribute_value = get_method_info(
-            method_function.module_name, method_function.method_func.__name__, attribute)
-        if attribute == "pattern":
-            if attribute_value in ["projection", "sinogram", "all"]:  
-                if attribute_value == "projection":
-                    pattern_upd = Pattern.projection
-                if attribute_value == "sinogram":
-                    pattern_upd = Pattern.sinogram
-                if attribute_value == "all":
-                    pattern_upd = Pattern.all
-                method_function = dataclasses.replace(method_function, pattern=pattern_upd)
-            else:
-                err_str = (
-                    f"The pattern {attribute_value} that is listed for the method "
-                    f"{method_function.module_name} is invalid."
-                )
-                log_exception(err_str)
-                raise ValueError(err_str)
-        if attribute == "implementation":
-            if attribute_value in ["cpu", "gpu", "gpu_cupy"]:
-                # as "cpu" is switched by default we need to consider GPU cases here only                
-                if attribute_value in ["gpu", "gpu_cupy"]:
-                    method_function = dataclasses.replace(method_function, cpu=False)
-                    method_function = dataclasses.replace(method_function, gpu=True)
-                    if attribute_value == "gpu_cupy":
-                        method_function = dataclasses.replace(method_function, cupyrun=True)
-            else:
-                err_str = (
-                    f"The implementation arch {attribute_value} that is listed for the method "
-                    f"{method_function.module_name} is invalid."
-                )
-                log_exception(err_str)
-                raise ValueError(err_str)
-        if attribute == "memory_gpu":
-            if attribute_value != 'None':
-                # memory gpu is active and needs to be calculated, saving params
-                method_function = dataclasses.replace(method_function, calc_max_slices=attribute_value)
-        if attribute == "output_dims_change":
-            if attribute_value:
-                method_function = dataclasses.replace(method_function, output_dims_change=True)
-    else:
-        err_str = (
-            f"The attribute {attribute} that is listed for the method "
-            f"{method_function.module_name} is invalid."
-        )
-        log_exception(err_str)
-        raise ValueError(err_str)
-    
-    return method_function
-
-
-def _determine_platform_sections(
-    method_funcs: List[MethodFunc],
-    save_all: bool,
-) -> List[PlatformSection]:
-    section: List[PlatformSection] = []
-    current_gpu = method_funcs[0].gpu
-    current_pattern = method_funcs[0].pattern
-    methods: List[MethodFunc] = []
-    
-    save_res_previous_method = save_all
-    for m_ind, method in enumerate(method_funcs):
-        if not save_all:
-            try:
-                save_res_current = method.parameters['save_result']
-            except:
-                save_res_current = False
-                
-            try:
-                global_stats = method.parameters['glob_stats']
-            except:
-                global_stats = False
-            if global_stats:
-                save_res_current = True # the stats has been requested, the section created
-        else:
-            save_res_current = True
-                    
-        if m_ind > 0 and save_res_previous_method:
-            # previous method requested the result to be saved,
-            # i.e., we need to create a section with that method or methods        
-            section.append(
-                PlatformSection(
-                    gpu=current_gpu,
-                    pattern=current_pattern,
-                    reslice=False,
-                    max_slices=0,
-                    methods=methods,
-                )
-            )
-            methods = [method]
-            current_pattern = method.pattern
-            current_gpu = method.gpu
-        else:        
-            if method.gpu == current_gpu and (
-                method.pattern == current_pattern
-                or method.pattern == Pattern.all
-                or current_pattern == Pattern.all
-            ):
-                methods.append(method)
-                if current_pattern == Pattern.all and method.pattern != Pattern.all:
-                    current_pattern = method.pattern
-            else:
-                section.append(
-                    PlatformSection(
-                        gpu=current_gpu,
-                        pattern=current_pattern,
-                        reslice=False,
-                        max_slices=0,
-                        methods=methods,
-                    )
-                )
-                methods = [method]
-                current_pattern = method.pattern
-                current_gpu = method.gpu
-        save_res_previous_method = save_res_current
-
-    section.append(
-        PlatformSection(
-            gpu=current_gpu, pattern=current_pattern, reslice=False, max_slices=0, methods=methods
-        )
-    )
-    for i, section_current in enumerate(section):                   
-        for m_ind, methodfunc_sect in enumerate(section_current.methods):
-            if m_ind == len(section_current.methods) - 1:
-                # we make every last method in the section to return_numpy: True
-                methodfunc_sect.return_numpy = True
-            if 'rotation' in methodfunc_sect.module_name:
-                # NOTE: we also need to check if centering is the last method in the section.
-                # Then what architecture of the previous method in that section AND the next 
-                # method in the next section after centering.
-                # This is due to TomoPy CPU functions can follow centering executed on the 
-                # GPU while the GPU data is not returned to CPU. 
-                # Centering doesn't care about the data being in blocks as it takes data from the CPU array                
-                if m_ind > 0:
-                    previous_method_arch = section_current.methods[m_ind-1].gpu
-                    if i > 0 and i < len(section) - 1:
-                        next_method_arch = section[i+1].methods[0].gpu
-                        if previous_method_arch != next_method_arch:
-                            section_current.methods[m_ind-1].return_numpy = True                      
-            
-    # we need to check if the reslice needed _after_ the section is complete
-    for i, section_current in enumerate(section):
-        # and we don't need to reslice for the last section
-        if i < len(section) - 1:   
-            if section_current.pattern.name != section[i+1].pattern.name and section[i+1].pattern.name != 'all':
-                # check that the pattern changed but exclude the case when next pattern is "all"
-                section[i].reslice = True
-            if i == 0 and section_current.pattern.name == 'all':
-                # if the first section has "all" pattern we skip the reslice 
-                section[i].reslice = False
-    return section
-
-
-def _get_available_gpu_memory(safety_margin_percent: float = 10.0) -> int:
-    try:
-        import cupy as cp
-
-        dev = cp.cuda.Device(local_rank)
-        # first, let's make some space
-        pool = cp.get_default_memory_pool()
-        pool.free_all_blocks()
-        cache = cp.fft.config.get_plan_cache()
-        cache.clear()
-        available_memory = dev.mem_info[0] + pool.free_bytes()
-        return int(available_memory * (1 - safety_margin_percent / 100.0))
-    except:
-        return int(100e9)  # arbitrarily high number - only used if GPU isn't available
-
-
-def _update_max_slices(
-    section: PlatformSection,
-    slicing_dim_section: int,
-    process_data_shape: Optional[Tuple[int, int, int]],
-    input_data_type: Optional[np.dtype],
-    dict_datasets_pipeline: Dict[str, ndarray],
-) -> Tuple[np.dtype, Tuple[int, int]]:
-    
-    comm = MPI.COMM_WORLD
-        
-    if process_data_shape is None or input_data_type is None:
-        return
-    
-    nsl_dim_l = list(process_data_shape)
-    nsl_dim_l.pop(slicing_dim_section)
-    non_slice_dims_shape = tuple(nsl_dim_l)
-    max_slices = process_data_shape[slicing_dim_section]
-    data_type = input_data_type # initialisation of data_type
-    output_dims = non_slice_dims_shape # initialisation of dims
-    if section.gpu:
-        available_memory = int(_get_available_gpu_memory(10.0))
-        available_memory_in_GB = round(available_memory / (1024**3), 2)
-        memory_str = f"The amount of the available GPU memory is {available_memory_in_GB} GB"
-        log_once(memory_str, comm=comm, colour=Colour.BVIOLET, level=1)
-        max_slices_methods = [max_slices] * len(section.methods)
-    
-    idx = 0
-    # loop over all methods in section
-    for m in section.methods:        
-        if m.output_dims_change:
-            # the "non_slice_dims_shape" needs to be changed for the current method
-            module_mem_path = "httomo.methods_database.packages.external."
-            for n_ind, module_str in enumerate(m.module_name.split(".")):
-                if n_ind == 0:
-                    module_mem_path += m.module_name.split(".")[0] + ".supporting_funcs"
-                else:
-                    module_mem_path += "."
-                    module_mem_path += m.module_name.split(".")[n_ind]
-            module_mem = getattr(import_module(module_mem_path), "_calc_output_dim_"+m.parameters['method_name'])                
-            output_dims = module_mem(non_slice_dims_shape, **m.parameters)        
-        if section.gpu:
-            if m.calc_max_slices != 'None':
-                # the gpu memory needs to be estimated for the given method
-                memory_bytes_method = 1
-                for i, dst in enumerate(m.calc_max_slices[0]['datasets']):
-                    subtract_bytes = 0
-                    # loop over the dataset names given in the library file and extracting 
-                    # the corresponding dimensions from the available datasets                    
-                    if dst in ["flats", "darks"]:
-                        # for normalisation module dealing with flats and darks separately
-                        df_bytes = np.prod(np.shape(dict_datasets_pipeline[dst])) * data_type.itemsize
-                        available_memory -= int(m.calc_max_slices[1]['multipliers'][i] * df_bytes)
-                    else:
-                        # deal with the rest of the data                        
-                        if m.calc_max_slices[2]['methods'][i] == 'direct':
-                            # this calculation assumes a direct (simple) correspondence through multiplier
-                            memory_bytes_method += int(m.calc_max_slices[1]['multipliers'][i] * np.prod(non_slice_dims_shape) * data_type.itemsize)
-                        else:
-                            # this requires more complicated memory calculation using the function that exists in methods_database of httomo
-                            # building up path to the function here:                     
-                            module_mem_path = "httomo.methods_database.packages.external."       
-                            for n_ind, module_str in enumerate(m.module_name.split(".")):
-                                if n_ind == 0:
-                                    module_mem_path += m.module_name.split(".")[0] + ".supporting_funcs"
-                                else:
-                                    module_mem_path += "."
-                                    module_mem_path += m.module_name.split(".")[n_ind]
-                            module_mem = getattr(import_module(module_mem_path), "_calc_memory_bytes_" + m.parameters['method_name'])
-                            (memory_bytes_method, subtract_bytes) = module_mem(non_slice_dims_shape, data_type, **m.parameters)
-
-                slices_estimated = (available_memory - subtract_bytes) // memory_bytes_method
-            else:
-                slices_estimated = max_slices            
-            max_slices_methods[idx] = min(max_slices, slices_estimated)            
-            idx += 1
-        non_slice_dims_shape = output_dims # overwrite input dims with estimated output ones
-                
-    if section.gpu:
-        section.max_slices = min(max_slices_methods)
-    else:
-        # non - gpu modules
-        section.max_slices = max_slices
-    # we need to return the full output_dims for the sections loop
-    output_dims_l = list(output_dims)
-    output_dims_l.insert(slicing_dim_section, max_slices)
-    output_dims = tuple(output_dims_l)
-    return output_dims, data_type
-
-
-def _perform_reslice(
-    data: np.ndarray,
-    current_section: PlatformSection,
-    next_section: PlatformSection,
-    reslice_info: ResliceInfo,
-    comm: MPI.Comm,
-) -> np.ndarray:
-    reslice_info.count += 1
-    if reslice_info.count > 1 and not reslice_info.has_warn_printed:
-        reslice_warn_str = (
-            f"WARNING: Reslicing is performed {reslice_info.count} times. The number of reslices increases the total run time."
-        )
-        log_once(reslice_warn_str, comm=comm, colour=Colour.RED)
-        reslice_info.has_warn_printed = True
-
-    current_slice_dim = _get_slicing_dim(current_section.pattern)
-    next_slice_dim = _get_slicing_dim(next_section.pattern)
-
-    if reslice_info.reslice_dir is None:
-        resliced_data, _ = reslice(
-            data,
-            current_slice_dim,
-            next_slice_dim,
-            comm,
-        )
-    else:
-        resliced_data, _ = reslice_filebased(
-            data,
-            current_slice_dim,
-            next_slice_dim,
-            comm,
-            reslice_info.reslice_dir,
-        )
-
-    return resliced_data
diff --git a/httomo/ui_layer.py b/httomo/ui_layer.py
new file mode 100644
index 000000000..00507e972
--- /dev/null
+++ b/httomo/ui_layer.py
@@ -0,0 +1,112 @@
+import yaml
+from typing import Any, Dict, List, Protocol
+from importlib import import_module, util
+from pathlib import Path
+import os
+import re
+
+from mpi4py import MPI
+from mpi4py.MPI import Comm
+
+from httomo.methods_database.query import MethodDatabaseRepository
+from httomo.runner.pipeline import Pipeline
+
+from httomo.runner.backend_wrapper import make_backend_wrapper
+from httomo.runner.loader import make_loader
+from httomo.runner.output_ref import OutputRef
+
+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. 
+    """
+    
+    def __init__(
+        self,
+        tasks_file_path: Path,
+        in_data_file_path: Path,
+        comm: Comm,
+    ):   
+        
+        self.repo = MethodDatabaseRepository()
+        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()]:
+            # loading yaml file with tasks provided
+            self.PipelineStageConfig = _yaml_loader(self.tasks_file_path)[0]
+        elif ext in ['.py', '.py'.upper()]:
+            # loading python file with tasks provided
+            self.PipelineStageConfig = _python_tasks_loader(self.tasks_file_path)
+        else:
+            raise ValueError(
+                f"The extension {ext} of the file {root} with tasks is unknown."
+            )
+
+    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,
+        )
+
+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
+
+def _python_tasks_loader(file_path: Path) -> list:
+    module_spec = util.spec_from_file_location("methods_to_list", file_path)
+    foo = util.module_from_spec(module_spec)
+    module_spec.loader.exec_module(foo)
+    tasks_list = list(foo.methods_to_list())
+    return tasks_list
\ No newline at end of file
diff --git a/httomo/utils.py b/httomo/utils.py
index 39c135ed3..a30febbd3 100644
--- a/httomo/utils.py
+++ b/httomo/utils.py
@@ -58,7 +58,7 @@ def log_once(output: Any, comm: Comm, colour: Any = Colour.GREEN, level=0) -> No
     colour : str, optional
         The colour of the output.
     level : int, optional
-        The level of the log message. 0 is info, 1 is debug.
+        The level of the log message. 0 is info, 1 is debug, 2 is warning.
     """
     if mpiutil.rank == 0:
         if isinstance(output, list):
@@ -69,11 +69,12 @@ def log_once(output: Any, comm: Comm, colour: Any = Colour.GREEN, level=0) -> No
             output = f"{colour}{output}{Colour.END}"
 
         if httomo.globals.logger is not None:
-            (
+            if level == 1:
                 httomo.globals.logger.debug(output)
-                if level == 1
-                else httomo.globals.logger.info(output)
-            )
+            elif level == 2:
+                httomo.globals.logger.warn(output)
+            else:
+                httomo.globals.logger.info(output)
 
 
 def log_rank(output: Any, comm: Comm) -> None:
@@ -169,13 +170,13 @@ def _parse_preview(
             step = slice_info.get("step", None)
 
             warn_on = False
-            if start is not None and start < 0 or start >= data_shape[idx]:
+            if start is not None and (start < 0 or start >= data_shape[idx]):
                 warn_on = True
                 str_warn = (
                     f"The 'start' preview {start} is outside the data dimension range"
                     + f" from 0 to {data_shape[idx]}"
                 )
-            if stop is not None and stop < 0 or stop >= data_shape[idx]:
+            if stop is not None and (stop < 0 or stop >= data_shape[idx]):
                 warn_on = True
                 str_warn = (
                     f"The 'stop' preview {start} is outside the data dimension range"
@@ -186,7 +187,7 @@ def _parse_preview(
                 str_warn = "The 'step' in preview cannot be negative"
 
             if warn_on:
-                log_exception(str_warn, comm=comm, colour=Colour.BACKG_RED, level=1)
+                log_exception(str_warn, colour=Colour.BACKG_RED)
                 raise ValueError("Preview error: " + str_warn)
 
             start_str = f"{start if start is not None else ''}"
@@ -271,7 +272,7 @@ def remove_ansi_escape_sequences(filename):
     """
     Remove ANSI escape sequences from a file.
     """
-    ansi_escape = re.compile(r"(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]")
+    ansi_escape = re.compile(r"(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]")    
     if Path(filename).exists():
         with open(filename, "r") as f:
             lines = f.readlines()
diff --git a/httomo/wrappers_class.py b/httomo/wrappers_class.py
deleted file mode 100644
index e29169b44..000000000
--- a/httomo/wrappers_class.py
+++ /dev/null
@@ -1,310 +0,0 @@
-from typing import Any, Callable, Dict, Tuple, Union
-import numpy as np
-import inspect
-from inspect import Parameter, signature
-import httomo.globals
-from httomo.utils import Colour, log_exception, log_once, log_rank, gpu_enabled, xp
-from httomo.data import mpiutil
-
-from mpi4py.MPI import Comm
-
-
-def _gpumem_cleanup():
-    """cleans up GPU memory and also the FFT plan cache"""
-    if gpu_enabled:
-        xp.get_default_memory_pool().free_all_blocks()
-        cache = xp.fft.config.get_plan_cache()
-        cache.clear()
-
-
-class BaseWrapper:
-    """A parent class for all wrappers in httomo that use external modules."""
-
-    def __init__(
-        self, backend_name: str, module_name: str, function_name: str, method_name: str, comm: Comm
-    ):
-        self.comm = comm        
-        self.module: Any = None
-        self.dict_params: Dict[str, Any] = {}
-        if gpu_enabled:
-            self.num_GPUs = xp.cuda.runtime.getDeviceCount()
-            _id = httomo.globals.gpu_id
-            # if gpu-id was specified in the CLI, use that
-            self.gpu_id = mpiutil.local_rank % self.num_GPUs if _id == -1 else _id
-
-    def _transfer_data(self, *args) -> Union[tuple, xp.ndarray, np.ndarray]:
-        """Transfer the data between the host and device for the GPU-enabled method
-
-        Returns:
-            Union[tuple, xp.ndarray, np.ndarray]: transferred datasets
-        """
-        if not gpu_enabled:
-            no_gpulog_str = "GPU is not available, please use only CPU methods"
-            log_once(no_gpulog_str, self.comm, colour=Colour.BVIOLET, level=1)
-            return args
-        xp.cuda.Device(self.gpu_id).use()        
-        gpulog_str = f"Using GPU {self.gpu_id} to transfer data of shape {xp.shape(args[0])}"
-        log_rank(gpulog_str, comm=self.comm)
-        _gpumem_cleanup()
-        if self.cupyrun:
-            if len(args) == 1:
-                return xp.asarray(args[0])
-            else:
-                return tuple(xp.asarray(d) for d in args)
-        else:
-            if len(args) == 1:
-                return xp.asnumpy(args[0])
-            else:
-                return tuple(xp.asnumpy(d) for d in args)
-
-    def _execute_generic(
-        self,
-        method_name: str,
-        dict_params_method: Dict,
-        data: xp.ndarray,
-        return_numpy: bool,
-        cupyrun: bool,
-    ) -> xp.ndarray:
-        """The generic wrapper to execute functions for external packages.
-
-        Args:
-            method_name (str): The name of the method to use.
-            dict_params_method (Dict): A dict containing parameters of the executed method.
-            data (xp.ndarray): a numpy or cupy data array.
-            return_numpy (bool): returns numpy array if set to True.
-            cupyrun (bool): True if the module uses CuPy API.
-
-        Returns:
-            xp.ndarray: A numpy or cupy array containing processed data.
-        """
-        self.cupyrun = cupyrun
-        # set the correct GPU ID if it is required
-        if "gpu_id" in dict_params_method:
-            dict_params_method["gpu_id"] = self.gpu_id
-
-        # check if data needs to be transfered host <-> device
-        data = self._transfer_data(data)
-
-        data = getattr(self.module, method_name)(data, **dict_params_method)
-        
-        if cupyrun and return_numpy:
-            # if data in CuPy array but we need numpy
-            return data.get() # get numpy
-        elif cupyrun and not return_numpy:
-            # if data in CuPy array and we need it
-            return data # return CuPy array
-        else:
-            return data # return numpy
-
-    def _execute_normalize(
-        self,
-        method_name: str,
-        dict_params_method: Dict,
-        data: xp.ndarray,
-        flats: xp.ndarray,
-        darks: xp.ndarray,
-        return_numpy: bool,
-        cupyrun: bool,
-    ) -> xp.ndarray:
-        """Normalisation-specific wrapper when flats and darks are required.
-
-        Args:
-            method_name (str): The name of the method to use.
-            dict_params_method (Dict): A dict containing parameters of the executed method.
-            data (xp.ndarray): a numpy or cupy data array.
-            flats (xp.ndarray): a numpy or cupy flats array.
-            darks (xp.ndarray): a numpy or darks flats array.
-            return_numpy (bool): returns numpy array if set to True.
-            cupyrun (bool): True if the module uses CuPy API.
-
-        Returns:
-            xp.ndarray: a numpy or cupy array of the normalised data.
-        """
-        self.cupyrun = cupyrun
-        # check where data needs to be transfered host <-> device
-        data, flats, darks = self._transfer_data(data, flats, darks)
-
-        data = getattr(self.module, method_name)(
-            data, flats, darks, **dict_params_method
-        )
-        
-        if cupyrun and return_numpy:
-            # if data in CuPy array but we need numpy
-            return data.get() # get numpy
-        elif cupyrun and not return_numpy:
-            # if data in CuPy array and we need it
-            return data # return CuPy array
-        else:
-            return data # return numpy        
-
-    def _execute_reconstruction(
-        self,
-        method_name: str,
-        dict_params_method: Dict,
-        data: xp.ndarray,
-        angles_radians: np.ndarray,
-        return_numpy: bool,
-        cupyrun: bool,
-    ) -> xp.ndarray:
-        """The image reconstruction wrapper.
-
-        Args:
-            method_name (str): The name of the method to use.
-            dict_params_method (Dict): A dict containing parameters of the executed method.
-            data (xp.ndarray): a numpy or cupy data array.
-            angles_radians (np.ndarray): a numpy array of projection angles.
-            return_numpy (bool): returns numpy array if set to True.
-            cupyrun (bool): True if the module uses CuPy API.
-
-        Returns:
-            xp.ndarray: a numpy or cupy array of the reconstructed data.
-        """
-        self.cupyrun = cupyrun
-        # set the correct GPU ID if it is required
-        if "gpu_id" in dict_params_method:
-            dict_params_method["gpu_id"] = self.gpu_id
-
-        # check if data needs to be transfered host <-> device
-        data = self._transfer_data(data)
-
-        # 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 = data.shape[0]
-        if datashape0 != len(angles_radians):
-            angles_radians = angles_radians[0:datashape0]
-
-        data = getattr(self.module, method_name)(
-            data, angles_radians, **dict_params_method
-        )        
-        if cupyrun and return_numpy:
-            # if data in CuPy array but we need numpy
-            return data.get() # get numpy
-        elif cupyrun and not return_numpy:
-            # if data in CuPy array and we need it
-            return data # return CuPy array
-        else:
-            return data # return numpy
-
-    def _execute_rotation(
-        self,
-        method_name: str,
-        dict_params_method: Dict,
-        data: xp.ndarray,
-        return_numpy: bool,
-        cupyrun: bool,
-    ) -> tuple | Any:
-        """The center of rotation wrapper.
-
-        Args:
-            method_name (str): The name of the method to use.
-            dict_params_method (Dict): A dict containing parameters of the executed method.
-            data (xp.ndarray): a numpy or cupy data array.
-            return_numpy (bool): returns numpy array if set to True.
-            cupyrun (bool): True if the module uses CuPy API.
-
-        Returns:
-            tuple: The center of rotation and other parameters if it is 360 sinogram.
-        """
-        self.cupyrun = cupyrun
-        # check where data needs to be transfered host <-> device
-        data = self._transfer_data(data)
-        method_func = getattr(self.module, method_name)
-        rot_center = 0
-        overlap = 0
-        side = 0
-        overlap_position = 0
-
-        if method_name == "find_center_360":
-            (rot_center, overlap, side, overlap_position) = method_func(
-                data, **dict_params_method
-            )
-            log_once(
-                f"###___The center of rotation for 360 degrees sinogram is {rot_center},"
-                + f" overlap {overlap}, side {side} and overlap position {overlap_position}___###",
-                self.comm,
-                colour=Colour.LYELLOW,
-                level=1,
-            )
-            return (rot_center, overlap, side, overlap_position)
-        elif method_name == "find_center_vo":
-            rot_center = method_func(data, **dict_params_method)
-            log_once(
-                    f"###____The center of rotation for 180 degrees sinogram is {rot_center}____###",
-                    comm=self.comm,
-                    colour=Colour.LYELLOW,
-                    level=1,)
-            return rot_center
-        else:
-            err_str = f"Invalid method name: {method_name}"
-            log_exception(err_str)
-            raise ValueError(err_str)
-
-    def _execute_images(
-        self,
-        method_name: str,
-        dict_params_method: Dict,
-        out_dir: str,
-        comm: Comm,
-        data: xp.ndarray,
-        return_numpy: bool,
-        cupyrun: bool,
-    ) -> None:
-        """Wrapper for save images function from httomolib.
-
-        Args:
-            method_name (str): The name of the method to use.
-            dict_params_method (Dict): A dict containing parameters of the executed method.
-            out_dir (str): The output directory.
-            comm (Comm): The MPI communicator.
-            data (xp.ndarray): a numpy or cupy data array.
-            return_numpy (bool): returns numpy array if set to True.
-            cupyrun (bool): True if the module uses CuPy API.
-
-        Returns:
-            None: returns None.
-        """
-        self.cupyrun = cupyrun
-        # check where data needs to be transfered host <-> device
-        data = self._transfer_data(data)
-                
-        if gpu_enabled:
-            data = getattr(self.module, method_name)(
-                xp.asnumpy(data), out_dir, comm_rank=comm.rank, **dict_params_method
-            )
-        else:
-            data = getattr(self.module, method_name)(
-                data, out_dir, comm_rank=comm.rank, **dict_params_method
-            )
-        return None
-
-
-class BackendWrapper(BaseWrapper):
-    """A class that wraps backend functions for httomo"""
-
-    def __init__(
-        self, backend_name: str, module_name: str, function_name: str, method_name: str, comm: Comm
-    ):
-        super().__init__(backend_name, module_name, function_name, method_name, comm)
-
-        # if not changed bellow the generic wrapper will be executed
-        self.wrapper_method: Callable = super()._execute_generic
-
-        if module_name in ["misc", "prep", "recon"]:
-            from importlib import import_module
-
-            self.module = getattr(
-                import_module(backend_name + "." + module_name), function_name
-            )
-            # deal with special cases 
-            if function_name == "normalize":
-                if method_name != "minus_log":
-                    func = getattr(self.module, method_name)
-                    sig_params = signature(func).parameters
-                    if "darks" or "dark" in sig_params and "flats" or "flat" in sig_params:
-                        self.wrapper_method = super()._execute_normalize
-            if function_name == "algorithm":
-                self.wrapper_method = super()._execute_reconstruction
-            if function_name == "rotation":
-                self.wrapper_method = super()._execute_rotation
-            if function_name == "images":
-                self.wrapper_method = super()._execute_images
diff --git a/httomo/yaml_checker.py b/httomo/yaml_checker.py
index d07372a97..97b23fdb8 100644
--- a/httomo/yaml_checker.py
+++ b/httomo/yaml_checker.py
@@ -2,14 +2,12 @@
 Module for checking the validity of yaml files.
 """
 import os
-from typing import Any, Generator, List, Optional, Tuple
+from typing import Any, Dict, Generator, Iterator, List, Optional, Tuple, TypeAlias
 
 import h5py
 import yaml
 
-from httomo.pipeline_reader import MethodConfig, PipelineConfig
 from httomo.utils import Colour
-from httomo.yaml_loader import YamlLoader
 from httomo.yaml_utils import get_external_package_current_version
 
 __all__ = [
@@ -18,8 +16,11 @@
     "validate_yaml_config",
 ]
 
+MethodConfig: TypeAlias = Dict[str, Dict[str, Any]]
+PipelineStageConfig: TypeAlias = List[MethodConfig]
+PipelineConfig: TypeAlias = List[PipelineStageConfig]
 
-def sanity_check(conf_generator: Generator) -> bool:
+def sanity_check(conf_generator: Iterator[Any]) -> bool:
     """
     Check if the yaml file is properly indented, has valid mapping and tags.
     """
@@ -231,7 +232,6 @@ def check_methods_exist_in_templates(conf: PipelineConfig) -> bool:
 
 def check_valid_method_parameters(
         conf: PipelineConfig,
-        loader: type[YamlLoader]
 ) -> bool:
     """
     Check each method config in the pipeline against the templates to see if
@@ -243,7 +243,7 @@ def check_valid_method_parameters(
 
     for f in template_yaml_files:
         with open(f, "r") as template:
-            method_dict = yaml.load(template, Loader=loader)[0]
+            method_dict = yaml.load(template, Loader=yaml.FullLoader)[0]
             template_yaml_data_list.append(
                 next(iter(method_dict.values()))
             )
@@ -348,9 +348,8 @@ def _store_hdf5_members(group, members_list, path=""):
 
 
 def validate_yaml_config(
-        yaml_file: str,
-        loader: type[YamlLoader],
-        in_file: Optional[str] = None
+        yaml_file: os.PathLike,
+        in_file: Optional[os.PathLike] = None
 ) -> bool:
     """
     Check that the modules, methods, and parameters in the `YAML_CONFIG` file
@@ -358,11 +357,11 @@ def validate_yaml_config(
     module in `httomo.templates`.
     """
     with open(yaml_file, "r") as f:
-        conf_generator: Generator = yaml.load_all(f, Loader=loader)
+        conf_generator: Iterator[Any] = yaml.load_all(f, Loader=yaml.FullLoader)
         is_yaml_ok = sanity_check(conf_generator)
 
     with open(yaml_file, "r") as f:
-        conf = list(yaml.load_all(f, Loader=loader))
+        conf = list(yaml.load_all(f, Loader=yaml.FullLoader))
 
     # Let all checks run before returning with the result, even if some checks
     # fail, to show all errors present in YAML
@@ -373,9 +372,9 @@ def validate_yaml_config(
     is_one_method_per_module = check_one_method_per_module(conf)
     are_hdf5_paths_correct = True
     if in_file is not None:
-        are_hdf5_paths_correct = check_hdf5_paths_against_loader(conf[0][0], in_file)
+        are_hdf5_paths_correct = check_hdf5_paths_against_loader(conf[0][0], str(in_file))
     do_methods_exist = check_methods_exist_in_templates(conf)
-    are_method_params_valid = check_valid_method_parameters(conf, loader)
+    are_method_params_valid = check_valid_method_parameters(conf)
 
     all_checks_pass = is_yaml_ok and \
         are_all_stages_defined and \
diff --git a/httomo/yaml_loader.py b/httomo/yaml_loader.py
deleted file mode 100644
index 78d3ba6f6..000000000
--- a/httomo/yaml_loader.py
+++ /dev/null
@@ -1,47 +0,0 @@
-from typing import Tuple, Union
-
-import yaml
-import numpy as np
-
-from httomo.utils import log_exception
-
-
-class YamlLoader(yaml.SafeLoader):
-    """
-    Load HTTomo pipeline YAML files.
-    """
-    def sweep_manual(self, node) -> Tuple[Union[int, float], ...]:
-        """
-        A parameter sweep defined by explicitly given values to sweep over.
-        This will simply be a tuple of the given values.
-        """
-        return tuple(self.construct_sequence(node))
-
-
-    def sweep_range(self, node) -> Tuple[Union[int, float], ...]:
-        """
-        A parameter sweep defined by a given range. This will be a tuple of
-        values which are defined based on the start, stop, step values that are
-        in the dict.
-        """
-        # Form a dict from the YAML marked by `!SweepRange`
-        range_dict = self.construct_mapping(node)
-        # Check that only 3 keys are in the given dict/mapping: `start`, `stop`,
-        # and `step`
-        keys = range_dict.keys()
-        is_valid_range = "start" in keys and "stop" in keys and "step" in keys
-        if not is_valid_range:
-            err_str = (
-                "Please provide `start`, `stop`, `step` values when "
-                "specifying a range to peform a parameter sweep over."
-            )
-            log_exception(err_str)
-            raise ValueError(err_str)
-
-        # Define the range based on the start, stop, step values
-        param_vals = np.arange(
-            range_dict["start"], range_dict["stop"], range_dict["step"]
-        )
-        param_vals = tuple(param_vals)
-        return param_vals
-
diff --git a/pyproject.toml b/pyproject.toml
index 95a486348..916d117ea 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -82,6 +82,7 @@ dev-cpu = [
   "pytest",
   "pytest-cov",
   "pytest-xdist",
+  "pytest-mock",
   "flake8",
   "pyproject-flake8",
   "pydocstyle",
@@ -89,11 +90,14 @@ dev-cpu = [
   "httomolib[dev]",
   "tomopy",
   "plumbum",
+  "mypy",
+  "types-PyYAML",
 ]
 dev-gpu = [
   "pytest",
   "pytest-cov",
   "pytest-xdist",
+  "pytest-mock",
   "flake8",
   "pyproject-flake8",
   "pydocstyle",
@@ -102,11 +106,14 @@ dev-gpu = [
   "httomolibgpu[dev]",
   "tomopy",
   "plumbum",
+  "mypy",
+  "types-PyYAML",
 ]
 
 [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
 
 [tool.isort]
 profile = "black"
@@ -143,7 +150,8 @@ testpaths = [
     "tests",
 ]
 filterwarnings = [
-    "ignore::ImportWarning"
+    "ignore::ImportWarning",
+    "ignore:distutils:DeprecationWarning:numexpr"
 ]
 
 
diff --git a/samples/loader_configs/diad.yaml b/samples/loader_configs/diad.yaml
index c42ffd25a..e4b04c54f 100644
--- a/samples/loader_configs/diad.yaml
+++ b/samples/loader_configs/diad.yaml
@@ -1,15 +1,16 @@
---- # loading
-- httomo.data.hdf.loaders:
-    standard_tomo:
-      name: tomo
-      data_path: /entry/imaging/data
-      image_key_path: /entry/instrument/imaging/image_key
-      rotation_angles:
-        data_path: /entry/imaging_sum/gts_theta_value
-      dimension: 1
-      preview:
-        -
-        - start: 5
-          stop: 7
-        -
-      pad: 0
+- method: standard_tomo
+  module_path: httomo.data.hdf.loaders
+  parameters:
+    name: tomo
+    in_file: tests/test_data/k11_diad/k11-18014.nxs
+    data_path: /entry/imaging/data
+    image_key_path: /entry/instrument/imaging/image_key
+    rotation_angles:
+      data_path: /entry/imaging_sum/gts_theta_value
+    dimension: 1
+    preview:
+      -
+      - start: 5
+        stop: 7
+      -
+    pad: 0
diff --git a/samples/loader_configs/ebic.yaml b/samples/loader_configs/ebic.yaml
index 313cc7fe8..115242004 100644
--- a/samples/loader_configs/ebic.yaml
+++ b/samples/loader_configs/ebic.yaml
@@ -1,15 +1,17 @@
-- httomo.data.hdf.loaders:
-    standard_tomo:
-      name: tomo
-      data_path: /exchange/data
-      rotation_angles:
+- method: standard_tomo
+  module_path: httomo.data.hdf.loaders
+  parameters:
+    name: tomo
+    in_file: # /path/to/data_file/
+    data_path: /exchange/data
+    rotation_angles:
         data_path: /exchange/theta
-      darks:
+    darks:
         file: # /path/to/file/containing/darks
         data_path: /exchange/data_dark
-      flats:
+    flats:
         file: # /path/to/file/containing/flats
         data_path: /exchange/data_white_post
-      dimension: 1
-      preview: [null, null, null]
-      pad: 0
+    dimension: 1
+    preview: [None, None, None]
+    pad: 0
\ No newline at end of file
diff --git a/samples/loader_configs/standard_tomo.yaml b/samples/loader_configs/standard_tomo.yaml
index f5aaeca9c..cbd0f94dc 100644
--- a/samples/loader_configs/standard_tomo.yaml
+++ b/samples/loader_configs/standard_tomo.yaml
@@ -1,13 +1,16 @@
---- # loading
-- httomo.data.hdf.loaders:
-    standard_tomo:
-      name: tomo
-      data_path: entry1/tomo_entry/data/data
-      image_key_path: entry1/tomo_entry/instrument/detector/image_key
-      dimension: 1
-      preview:
-        - 
-        - start: 0
-          stop: 3
-        - 
-      pad: 0
+- method: standard_tomo
+  module_path: httomo.data.hdf.loaders
+  parameters:
+    name: tomo
+    in_file: tests/test_data/tomo_standard.nxs
+    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: 57
+        stop: 60
+      -
+    pad: 0
diff --git a/samples/pipeline_template_examples/01_basic_cpu_pipeline_tomo_standard.yaml b/samples/pipeline_template_examples/01_basic_cpu_pipeline_tomo_standard.yaml
deleted file mode 100644
index 3cc118b26..000000000
--- a/samples/pipeline_template_examples/01_basic_cpu_pipeline_tomo_standard.yaml
+++ /dev/null
@@ -1,50 +0,0 @@
-- httomo.data.hdf.loaders:
-    standard_tomo:
-      name: tomo
-      data_path: entry1/tomo_entry/data/data
-      image_key_path: entry1/tomo_entry/instrument/detector/image_key
-      dimension: 1
-      preview:
-        - 
-        - 
-        - 
-      pad: 0
-- tomopy.prep.normalize:
-    normalize:
-      data_in: tomo
-      data_out: tomo
-      cutoff: null
-- tomopy.prep.normalize:
-    minus_log:
-      data_in: tomo
-      data_out: tomo
-- tomopy.recon.rotation:
-    find_center_vo:
-      data_in: tomo
-      data_out: cor
-      ind: mid
-      smin: -50
-      smax: 50
-      srad: 6
-      step: 0.25
-      ratio: 0.5
-      drop: 20
-- tomopy.recon.algorithm:
-    recon:
-      data_in: tomo
-      data_out: tomo
-      center: cor
-      sinogram_order: false
-      algorithm: gridrec
-      init_recon: null
-      #additional parameters': AVAILABLE
-- httomolib.misc.images:
-    save_to_images:
-      data_in: tomo
-      subfolder_name: images
-      axis: 0
-      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/02_basic_cpu_pipeline_tomo_standard.yaml b/samples/pipeline_template_examples/02_basic_cpu_pipeline_tomo_standard.yaml
deleted file mode 100644
index 8616f2d76..000000000
--- a/samples/pipeline_template_examples/02_basic_cpu_pipeline_tomo_standard.yaml
+++ /dev/null
@@ -1,69 +0,0 @@
---- # loading
-- httomo.data.hdf.loaders:
-    standard_tomo:
-      name: tomo
-      data_path: entry1/tomo_entry/data/data
-      image_key_path: entry1/tomo_entry/instrument/detector/image_key
-      dimension: 1
-      preview:
-        - 
-        - start: 30
-          stop: 60
-        - 
-      pad: 0
---- # pre-normalisation / pre-processing
-- tomopy.recon.rotation:
-    find_center_vo:
-      data_in: tomo
-      data_out: cor
-      ind: mid
-      smin: -50
-      smax: 50
-      srad: 6
-      step: 0.25
-      ratio: 0.5
-      drop: 20
---- # main processing
-- tomopy.prep.normalize:
-    normalize:
-      data_in: tomo
-      data_out: tomo
-      cutoff: null
-      averaging: mean
-- tomopy.prep.normalize:
-    minus_log:
-      data_in: tomo
-      data_out: tomo
-- tomopy.prep.stripe:
-    remove_stripe_fw:
-      data_in: tomo
-      data_out: tomo
-      level: null
-      wname: db5
-      sigma: 2
-      pad: true
-- tomopy.recon.algorithm:
-    recon:
-      data_in: tomo
-      data_out: tomo
-      center: cor
-      sinogram_order: false
-      algorithm: gridrec
-      init_recon: null
-      #additional parameters': AVAILABLE
-- tomopy.misc.corr:
-    median_filter:
-      data_in: tomo
-      data_out: tomo
-      size: 3
-      axis: 0
-- httomolib.misc.images:
-    save_to_images:
-      data_in: tomo
-      subfolder_name: images
-      axis: 0
-      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/03_basic_gpu_pipeline_tomo_standard.yaml b/samples/pipeline_template_examples/03_basic_gpu_pipeline_tomo_standard.yaml
deleted file mode 100644
index ae7365f28..000000000
--- a/samples/pipeline_template_examples/03_basic_gpu_pipeline_tomo_standard.yaml
+++ /dev/null
@@ -1,60 +0,0 @@
---- # Loading stage
-- httomo.data.hdf.loaders:
-    standard_tomo:
-      name: tomo
-      data_path: entry1/tomo_entry/data/data
-      image_key_path: entry1/tomo_entry/instrument/detector/image_key
-      dimension: 1
-      preview:
-        - 
-        - 
-        - 
-      pad: 0
---- # Pre-normalisation / pre-processing stage
-- httomolibgpu.misc.corr:
-    remove_outlier3d:
-      kernel_size: 3
-      dif: 0.1
-- httomolibgpu.recon.rotation:
-    find_center_vo:
-      data_in: tomo
-      data_out: cor
-      ind: mid
-      smin: -50
-      smax: 50
-      srad: 6.0
-      step: 0.25
-      ratio: 0.5
-      drop: 20
---- # Main processing stage
-- httomolibgpu.prep.normalize:
-    normalize:
-      data_in: tomo
-      data_out: tomo
-      cutoff: 10.0
-      minus_log: true
-      nonnegativity: false
-- httomolibgpu.prep.stripe:
-    remove_stripe_based_sorting:
-      data_in: tomo
-      data_out: tomo
-      size: 11
-      dim: 1
-- httomolibgpu.recon.algorithm:
-    FBP:
-      data_in: tomo
-      data_out: tomo
-      center: cor
-      recon_size: null
-      recon_mask_radius: null
-- httomolib.misc.images:
-    save_to_images:
-      data_in: tomo
-      subfolder_name: images
-      axis: 1
-      file_format: tif
-      bits: 8
-      perc_range_min: 0.0
-      perc_range_max: 100.0
-      jpeg_quality: 95
-      glob_stats: true
diff --git a/samples/pipeline_template_examples/DLS/01_diad_pipeline.yaml b/samples/pipeline_template_examples/DLS/01_diad_pipeline.yaml
deleted file mode 100644
index b22f9b729..000000000
--- a/samples/pipeline_template_examples/DLS/01_diad_pipeline.yaml
+++ /dev/null
@@ -1,61 +0,0 @@
-- httomo.data.hdf.loaders:
-    standard_tomo:
-      name: tomo
-      data_path: /entry/imaging/data
-      image_key_path: /entry/instrument/imaging/image_key
-      rotation_angles:
-        data_path: /entry/imaging_sum/gts_theta_value
-      dimension: 1
-      preview:
-        - 
-        - start: 0
-          stop: 20
-        - 
-      pad: 0
-- tomopy.prep.normalize:
-    normalize:
-      data_in: tomo
-      data_out: tomo
-      cutoff: null
-- tomopy.prep.normalize:
-    minus_log:
-      data_in: tomo
-      data_out: tomo
-- tomopy.recon.rotation:
-    find_center_vo:
-      data_in: tomo
-      data_out: cor
-      ind: mid
-      smin: -50
-      smax: 50
-      srad: 6
-      step: 0.25
-      ratio: 0.5
-      drop: 20      
-- tomopy.prep.stripe:
-    remove_all_stripe:
-      data_in: tomo
-      data_out: tomo
-      snr: 3
-      la_size: 61
-      sm_size: 21
-      dim: 1
-- tomopy.recon.algorithm:
-    recon:
-      data_in: tomo
-      data_out: tomo
-      center: cor
-      sinogram_order: false
-      algorithm: gridrec
-      init_recon: null
-      #additional parameters': AVAILABLE
-- httomolib.misc.images:
-    save_to_images:
-      data_in: tomo
-      subfolder_name: images
-      axis: 0
-      file_format: tif
-      bits: 8
-      perc_range_min: 0.0
-      perc_range_max: 100.0
-      jpeg_quality: 95
\ No newline at end of file
diff --git a/samples/pipeline_template_examples/DLS/01_diad_pipeline_gpu.yaml b/samples/pipeline_template_examples/DLS/01_diad_pipeline_gpu.yaml
new file mode 100644
index 000000000..85f52724a
--- /dev/null
+++ b/samples/pipeline_template_examples/DLS/01_diad_pipeline_gpu.yaml
@@ -0,0 +1,69 @@
+- method: standard_tomo
+  module_path: httomo.data.hdf.loaders
+  parameters:
+    name: tomo
+    data_path: /entry/imaging/data
+    image_key_path: /entry/instrument/imaging/image_key
+    rotation_angles:
+        data_path: /entry/imaging_sum/gts_theta_value
+    dimension: 1
+    preview:
+      -
+      - start: 8
+        stop: 15
+      -
+    pad: 0
+- method: find_center_vo
+  module_path: httomolibgpu.recon.rotation
+  parameters:
+    ind: mid
+    smin: -50
+    smax: 50
+    srad: 6
+    step: 0.25
+    ratio: 0.5
+    drop: 20
+  id: centering
+  side_outputs:
+    cor: centre_of_rotation
+- method: remove_outlier3d
+  module_path: httomolibgpu.misc.corr
+  parameters:
+    dif: 0.1
+    kernel_size: 3
+- method: normalize
+  module_path: httomolibgpu.prep.normalize
+  parameters:
+    cutoff: 10.0
+    minus_log: true
+    nonnegativity: false
+    remove_nans: false
+- method: remove_stripe_based_sorting
+  module_path: httomolibgpu.prep.stripe
+  parameters:
+    size: 11
+    dim: 1
+- method: FBP
+  module_path: httomolibgpu.recon.algorithm
+  parameters:
+    center: ${{centering.side_outputs.centre_of_rotation}}
+    recon_size: null
+    recon_mask_radius: null
+  save_result: true
+- 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: 1
+    file_format: tif
+    bits: 8
+    perc_range_min: 0.0
+    perc_range_max: 95.0
+    jpeg_quality: 95
+    glob_stats: ${{statistics.side_outputs.glob_stats}}
diff --git a/samples/pipeline_template_examples/DLS/02_i12_360scan_pipeline.yaml b/samples/pipeline_template_examples/DLS/02_i12_360scan_pipeline.yaml
deleted file mode 100644
index cacd60618..000000000
--- a/samples/pipeline_template_examples/DLS/02_i12_360scan_pipeline.yaml
+++ /dev/null
@@ -1,63 +0,0 @@
-- httomo.data.hdf.loaders:
-    standard_tomo:
-      name: tomo
-      data_path: entry1/tomo_entry/data/data
-      image_key_path: entry1/tomo_entry/instrument/detector/image_key
-      dimension: 1
-      preview:
-        - 
-        - start: 30
-          stop: 60
-        - 
-      pad: 0
-- httomolibgpu.misc.corr:
-    remove_outlier3d:
-      data_in_multi: [tomo, flats, darks]
-      data_out_multi: [tomo, flats, darks]
-      kernel_size: 3
-      dif: 0.1      
-- httomolibgpu.prep.normalize:
-    normalize:
-      data_in: tomo
-      data_out: tomo
-      cutoff: 10.0
-      minus_log: true
-      nonnegativity: false 
-- httomolibgpu.recon.rotation:
-    find_center_360:
-      data_in: tomo
-      data_out: [cor, overlap, side, overlap_position]
-      ind: mid
-      win_width: 10
-      side: null
-      denoise: true
-      norm: false
-      use_overlap: true
-- httomolibgpu.prep.stripe:
-    remove_stripe_based_sorting:
-      data_in: tomo
-      data_out: tomo
-      size: 11
-      dim: 1
-- httomolibgpu.misc.morph:
-    sino_360_to_180:
-      data_in: tomo
-      data_out: tomo
-      overlap: overlap
-      rotation: right 
-- httomolibgpu.recon.algorithm:
-     reconstruct_tomobar:
-       data_in: tomo
-       data_out: tomo
-       center: cor
-       objsize: null
-- httomolib.misc.images:
-    save_to_images:
-      data_in: tomo
-      subfolder_name: images
-      axis: 0
-      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/DLS/03_i12_separate_darks_flats.yaml b/samples/pipeline_template_examples/DLS/03_i12_separate_darks_flats.yaml
index 1a2b43dff..35255291e 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
@@ -1,18 +1,23 @@
-- httomo.data.hdf.loaders:
-    standard_tomo:
-      name: tomo
-      data_path: /1-TempPlugin-tomo/data
-      rotation_angles:
-        user_defined:
-          start_angle: 0
-          stop_angle: 180
-          angles_total: 724
-      dimension: 1
-      preview: [null, null, null]
-      pad: 0
-      darks:
-        file: tests/test_data/i12/separate_flats_darks/dark_field.h5
-        data_path: /1-NoProcessPlugin-tomo/data
-      flats:
-        file: tests/test_data/i12/separate_flats_darks/flat_field.h5
-        data_path: /1-NoProcessPlugin-tomo/data
+- method: standard_tomo
+  module_path: httomo.data.hdf.loaders
+  parameters:
+    name: tomo
+    in_file: tests/test_data/i12/separate_flats_darks/i12_dynamic_start_stop180.nxs
+    data_path: /1-TempPlugin-tomo/data
+    rotation_angles:
+      user_defined:
+        start_angle: 0
+        stop_angle: 180
+        angles_total: 724
+    dimension: 1
+    preview:
+      -
+      - 
+      -
+    pad: 0
+    darks:
+      file: tests/test_data/i12/separate_flats_darks/dark_field.h5
+      data_path: /1-NoProcessPlugin-tomo/data
+    flats:
+      file: tests/test_data/i12/separate_flats_darks/flat_field.h5
+      data_path: /1-NoProcessPlugin-tomo/data
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 bc5e6de9d..8d0d8239c 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
@@ -1,24 +1,29 @@
-- httomo.data.hdf.loaders:
-    standard_tomo:
-      name: tomo
-      data_path: /1-TempPlugin-tomo/data
-      rotation_angles:
-        user_defined:
-          start_angle: 0
-          stop_angle: 180
-          angles_total: 724
-      dimension: 1
-      preview: [null, null, null]
-      pad: 0
-      darks:
-        file: tests/test_data/i12/separate_flats_darks/dark_field.h5
-        data_path: /1-NoProcessPlugin-tomo/data
-      ignore_darks:
-        individual: [0, 5, 8, 10, 15]
-      flats:
-        file: tests/test_data/i12/separate_flats_darks/flat_field.h5
-        data_path: /1-NoProcessPlugin-tomo/data
-      ignore_flats:
-        batch:
-          - start: 0
-            stop: 5
+- method: standard_tomo
+  module_path: httomo.data.hdf.loaders
+  parameters:
+    name: tomo
+    in_file: tests/test_data/i12/separate_flats_darks/i12_dynamic_start_stop180.nxs
+    data_path: /1-TempPlugin-tomo/data
+    rotation_angles:
+      user_defined:
+        start_angle: 0
+        stop_angle: 180
+        angles_total: 724
+    dimension: 1
+    preview:
+      -
+      - 
+      -
+    pad: 0
+    darks:
+      file: tests/test_data/i12/separate_flats_darks/dark_field.h5
+      data_path: /1-NoProcessPlugin-tomo/data
+    ignore_darks:
+      individual: [0, 5, 8, 10, 15]
+    flats:
+      file: tests/test_data/i12/separate_flats_darks/flat_field.h5
+      data_path: /1-NoProcessPlugin-tomo/data
+    ignore_flats:
+      batch:
+        - start: 0
+          stop: 5
diff --git a/samples/pipeline_template_examples/parameter_sweeps/01_recon_cor_range_sweep.yaml b/samples/pipeline_template_examples/parameter_sweeps/01_recon_cor_range_sweep.yaml
deleted file mode 100644
index e84805f2c..000000000
--- a/samples/pipeline_template_examples/parameter_sweeps/01_recon_cor_range_sweep.yaml
+++ /dev/null
@@ -1,18 +0,0 @@
-- httomo.data.hdf.loaders:
-    standard_tomo:
-      name: tomo
-      data_path: /entry1/tomo_entry/data/data
-      image_key_path: /entry1/tomo_entry/instrument/detector/image_key
-      dimension: 1
-      pad: 0
-- tomopy.recon.algorithm:
-    recon:
-      data_in: tomo
-      data_out: tomo
-      center: !SweepRange
-        start: 10
-        stop: 50
-        step: 10
-      sinogram_order: false
-      algorithm: gridrec
-      init_recon: null
diff --git a/samples/pipeline_template_examples/parameter_sweeps/02_median_filter_kernel_sweep.yaml b/samples/pipeline_template_examples/parameter_sweeps/02_median_filter_kernel_sweep.yaml
deleted file mode 100644
index 8e6dfe6d4..000000000
--- a/samples/pipeline_template_examples/parameter_sweeps/02_median_filter_kernel_sweep.yaml
+++ /dev/null
@@ -1,29 +0,0 @@
---- # loading
-- httomo.data.hdf.loaders:
-    standard_tomo:
-      name: tomo
-      data_path: /entry1/tomo_entry/data/data
-      image_key_path: /entry1/tomo_entry/instrument/detector/image_key
-      dimension: 1
-      pad: 0
---- # pre-normalisation / pre-processing
-- tomopy.recon.rotation:
-    find_center_vo:
-      data_in: tomo
-      data_out: cor
-      ind: mid
-      smin: -50
-      smax: 50
-      srad: 6
-      step: 0.25
-      ratio: 0.5
-      drop: 20
---- # main processing
-- tomopy.misc.corr:
-    median_filter:
-      data_in: tomo
-      data_out: tomo
-      size: !Sweep
-        - 3
-        - 5
-      axis: 0
diff --git a/samples/pipeline_template_examples/parameter_sweeps/03_median_filter_image_saver.yaml b/samples/pipeline_template_examples/parameter_sweeps/03_median_filter_image_saver.yaml
deleted file mode 100644
index 186a27f79..000000000
--- a/samples/pipeline_template_examples/parameter_sweeps/03_median_filter_image_saver.yaml
+++ /dev/null
@@ -1,29 +0,0 @@
-- httomo.data.hdf.loaders:
-    standard_tomo:
-      name: tomo
-      data_path: entry1/tomo_entry/data/data
-      image_key_path: entry1/tomo_entry/instrument/detector/image_key
-      dimension: 1
-      preview:
-        - 
-        - 
-        - 
-      pad: 0
-- httomolib.misc.corr:
-    median_filter3d:
-      data_in: tomo
-      data_out: tomo
-      kernel_size: !Sweep
-        - 3
-        - 5
-      dif: 0.0
-- httomolib.misc.images:
-    save_to_images:
-      data_in: tomo
-      subfolder_name: images
-      axis: 0
-      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/parameter_sweeps/04_phase_retrieve_image_saver.yaml b/samples/pipeline_template_examples/parameter_sweeps/04_phase_retrieve_image_saver.yaml
deleted file mode 100644
index 3741160d5..000000000
--- a/samples/pipeline_template_examples/parameter_sweeps/04_phase_retrieve_image_saver.yaml
+++ /dev/null
@@ -1,63 +0,0 @@
-- httomo.data.hdf.loaders:
-    standard_tomo:
-      name: tomo
-      data_path: /entry1/tomo_entry/data/data
-      image_key_path: /entry1/tomo_entry/instrument/detector/image_key
-      preview:
-        - 
-        - start: 30
-          stop: 60
-        -      
-      dimension: 1
-      pad: 0
-- tomopy.prep.normalize:
-    normalize:
-      data_in: tomo
-      data_out: tomo
-      cutoff: null
-- tomopy.prep.normalize:
-    minus_log:
-      data_in: tomo
-      data_out: tomo
-- tomopy.prep.phase:
-   retrieve_phase:
-     data_in: tomo
-     data_out: tomo
-     pixel_size: 0.0001
-     dist: 50
-     energy: 20
-     alpha: !SweepRange
-        start: 0.001
-        stop: 0.01
-        step: 0.001
-     pad: true
-- tomopy.recon.rotation:
-    find_center_vo:
-      data_in: tomo
-      data_out: cor
-      ind: mid
-      smin: -50
-      smax: 50
-      srad: 6
-      step: 0.25
-      ratio: 0.5
-      drop: 20      
-- tomopy.recon.algorithm:
-    recon:
-      data_in: tomo
-      data_out: tomo
-      center: cor
-      sinogram_order: false
-      algorithm: gridrec
-      init_recon: null
-      #additional parameters': AVAILABLE
-- httomolib.misc.images:
-    save_to_images:
-      data_in: tomo
-      subfolder_name: images
-      axis: 0
-      file_format: tif
-      bits: 8
-      perc_range_min: 0.0
-      perc_range_max: 100.0
-      jpeg_quality: 95
\ No newline at end of file
diff --git a/samples/pipeline_template_examples/pipeline_360deg_gpu2.yaml b/samples/pipeline_template_examples/pipeline_360deg_gpu2.yaml
new file mode 100644
index 000000000..0297685b2
--- /dev/null
+++ b/samples/pipeline_template_examples/pipeline_360deg_gpu2.yaml
@@ -0,0 +1,75 @@
+- 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:
+      -
+      -
+      -
+    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: remove_outlier3d
+  module_path: httomolibgpu.misc.corr
+  parameters:
+    dif: 0.1
+    kernel_size: 3
+- method: normalize
+  module_path: httomolibgpu.prep.normalize
+  parameters:
+    cutoff: 10.0
+    minus_log: true
+    nonnegativity: false
+    remove_nans: false
+- method: remove_stripe_based_sorting
+  module_path: httomolibgpu.prep.stripe
+  parameters:
+    size: 11
+    dim: 1
+- method: sino_360_to_180
+  module_path: httomolibgpu.misc.morph
+  parameters:
+    overlap: ${{centering.side_outputs.overlap}}
+    rotation: right    
+- method: FBP
+  module_path: httomolibgpu.recon.algorithm
+  parameters:
+    center: ${{centering.side_outputs.centre_of_rotation}}
+    recon_size: null
+    recon_mask_radius: null
+  save_result: true
+- 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: 1
+    file_format: tif
+    bits: 8
+    perc_range_min: 0.0
+    perc_range_max: 95.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
new file mode 100644
index 000000000..ec416f3ce
--- /dev/null
+++ b/samples/pipeline_template_examples/pipeline_cpu1.yaml
@@ -0,0 +1,52 @@
+- 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:
+      - 
+      - 
+      - 
+    pad: 0
+- method: normalize
+  module_path: tomopy.prep.normalize
+  parameters:
+    cutoff: null
+    averaging: mean
+- method: minus_log
+  module_path: tomopy.prep.normalize
+  parameters: {}
+- method: find_center_vo
+  module_path: tomopy.recon.rotation
+  parameters:
+    ind: null
+    smin: -50
+    smax: 50
+    srad: 6
+    step: 0.25
+    ratio: 0.5
+    drop: 20
+  id: centering
+  side_outputs:
+    cor : centre_of_rotation
+- 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: 1
+    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_cpu2.yaml b/samples/pipeline_template_examples/pipeline_cpu2.yaml
new file mode 100644
index 000000000..e221e5c59
--- /dev/null
+++ b/samples/pipeline_template_examples/pipeline_cpu2.yaml
@@ -0,0 +1,73 @@
+- 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: 30
+        stop: 60
+      -
+    pad: 0
+- method: find_center_vo
+  module_path: tomopy.recon.rotation
+  parameters:
+    ind: mid
+    smin: -50
+    smax: 50
+    srad: 6
+    step: 0.25
+    ratio: 0.5
+    drop: 20
+  id: centering
+  side_outputs:
+    cor: centre_of_rotation
+- method: remove_outlier
+  module_path: tomopy.misc.corr
+  parameters:
+    dif: 0.1
+    size: 3
+    axis: 0
+- method: normalize
+  module_path: tomopy.prep.normalize
+  parameters:
+    cutoff: null
+    averaging: mean
+- method: minus_log
+  module_path: tomopy.prep.normalize
+  parameters: {}
+- method: remove_stripe_fw
+  module_path: tomopy.prep.stripe
+  parameters:
+    level: null
+    wname: db5
+    sigma: 2
+    pad: true
+- method: recon
+  module_path: tomopy.recon.algorithm
+  parameters:
+    center: ${{centering.side_outputs.centre_of_rotation}}
+    sinogram_order: false
+    algorithm: gridrec
+    init_recon: null
+    #additional parameters: AVAILABLE
+  save_result: true
+- method: median_filter
+  module_path: tomopy.misc.corr
+  parameters:
+    size: 3
+    axis: 0
+- method: save_to_images
+  module_path: httomolib.misc.images
+  parameters:
+    subfolder_name: images
+    axis: 1
+    file_format: tif
+    bits: 8
+    perc_range_min: 0.0
+    perc_range_max: 100.0
+    jpeg_quality: 95
diff --git a/samples/pipeline_template_examples/pipeline_cpu3.yaml b/samples/pipeline_template_examples/pipeline_cpu3.yaml
new file mode 100644
index 000000000..7a1b90f1d
--- /dev/null
+++ b/samples/pipeline_template_examples/pipeline_cpu3.yaml
@@ -0,0 +1,67 @@
+- 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:
+      -
+      -
+      -
+    pad: 0
+- method: find_center_vo
+  module_path: tomopy.recon.rotation
+  parameters:
+    ind: mid
+    smin: -50
+    smax: 50
+    srad: 6
+    step: 0.25
+    ratio: 0.5
+    drop: 20
+  id: centering
+  side_outputs:
+    cor: centre_of_rotation
+- method: remove_outlier
+  module_path: tomopy.misc.corr
+  parameters:
+    dif: 0.1
+    size: 3
+    axis: 0
+- 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
+    #additional parameters: AVAILABLE
+- 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: 1
+    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}}
+    
diff --git a/samples/pipeline_template_examples/pipeline_gpu1.yaml b/samples/pipeline_template_examples/pipeline_gpu1.yaml
new file mode 100644
index 000000000..154cf6aa1
--- /dev/null
+++ b/samples/pipeline_template_examples/pipeline_gpu1.yaml
@@ -0,0 +1,61 @@
+- 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:
+      -
+      -
+      -
+    pad: 0
+- method: find_center_vo
+  module_path: httomolibgpu.recon.rotation
+  parameters:
+    ind: mid
+    smin: -50
+    smax: 50
+    srad: 6
+    step: 0.25
+    ratio: 0.5
+    drop: 20
+  id: centering
+  side_outputs:
+    cor: centre_of_rotation
+- method: remove_outlier3d
+  module_path: httomolibgpu.misc.corr
+  parameters:
+    dif: 0.1
+    kernel_size: 3
+- method: normalize
+  module_path: httomolibgpu.prep.normalize
+  parameters:
+    cutoff: 10.0
+    minus_log: true
+    nonnegativity: false
+    remove_nans: false
+- method: remove_stripe_based_sorting
+  module_path: httomolibgpu.prep.stripe
+  parameters:
+    size: 11
+    dim: 1
+- method: FBP
+  module_path: httomolibgpu.recon.algorithm
+  parameters:
+    center: ${{centering.side_outputs.centre_of_rotation}}
+    recon_size: null
+    recon_mask_radius: null
+  save_result: true
+- method: save_to_images
+  module_path: httomolib.misc.images
+  parameters:
+    subfolder_name: images
+    axis: 1
+    file_format: tif
+    bits: 8
+    perc_range_min: 0.0
+    perc_range_max: 100.0
+    jpeg_quality: 95
diff --git a/httomo/methods_database/packages/external/tomopy/1.14/__init__.py b/samples/python_templates/__init__.py
similarity index 100%
rename from httomo/methods_database/packages/external/tomopy/1.14/__init__.py
rename to samples/python_templates/__init__.py
diff --git a/samples/python_templates/pipeline_cpu1.py b/samples/python_templates/pipeline_cpu1.py
new file mode 100644
index 000000000..1f0e32ebb
--- /dev/null
+++ b/samples/python_templates/pipeline_cpu1.py
@@ -0,0 +1,91 @@
+from typing import Any, Dict, List, TypeAlias
+
+MethodConfig: TypeAlias = Dict[str, Dict[str, Any]]
+PipelineStageConfig: TypeAlias = List[MethodConfig]
+PipelineConfig: TypeAlias = List[PipelineStageConfig]
+
+# NOTE: when creating a Pythonic pipeline, please use
+# the function's name "methods_to_list" so it will be 
+# found by the loader
+
+def methods_to_list() -> PipelineConfig:
+    """Pythonic way to build a list of tasks
+    from which Pipeline can be generated in Httomo.
+    This accompaniments the YAML interface.
+
+    Returns:
+        PipelineConfig: A list of tasks in "full_pipeline_list" that can be executed in Httomo.
+    """
+    full_pipeline_list = []
+    loader = {
+        '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': [None, None, None],
+                     'pad': 0,
+                       },
+                    }
+    full_pipeline_list.append(loader)
+    method1 = {
+        'method': "normalize",
+        'module_path': "tomopy.prep.normalize",
+        'parameters' : {
+                     'cutoff': None,
+                       },
+                    }
+    full_pipeline_list.append(method1)
+    method2 = {
+        'method': "minus_log",
+        'module_path': "tomopy.prep.normalize",
+        'parameters' : {},
+                    }
+    full_pipeline_list.append(method2)
+    method3 = {
+        'method': "find_center_vo",
+        'module_path': "tomopy.recon.rotation",
+        'id': "centering",
+        'parameters' : {
+                     'ind': "mid",
+                     'smin': -50,
+                     'smax': 50,
+                     'srad': 6,
+                     'step': 0.25,
+                     'ratio': 0.5,
+                     'drop': 20,
+                       },
+        'side_outputs': {"cor": "centre_of_rotation"},
+                    }
+    full_pipeline_list.append(method3)
+    method4 = {
+        'method': "recon",
+        'module_path': "tomopy.recon.algorithm",
+        'parameters' : {
+                     'center': "${{centering.side_outputs.centre_of_rotation}}",
+                     'sinogram_order': False,
+                     'algorithm': "gridrec",
+                     'init_recon': None,
+                       },
+                    }
+    full_pipeline_list.append(method4)
+    method5 = {
+        'method': "save_to_images",
+        'module_path': "httomolib.misc.images",
+        'parameters' : {
+                     'subfolder_name': "images",
+                     'axis': 1,
+                     'file_format': "tif",
+                     'bits': 8,
+                     'perc_range_min': 0.0,
+                     'perc_range_max': 100.0,
+                     'jpeg_quality': 95,
+                       },
+                    }
+    full_pipeline_list.append(method5)
+
+    return full_pipeline_list
+    
diff --git a/samples/python_templates/pipeline_cpu2.py b/samples/python_templates/pipeline_cpu2.py
new file mode 100644
index 000000000..3c50fe259
--- /dev/null
+++ b/samples/python_templates/pipeline_cpu2.py
@@ -0,0 +1,122 @@
+from typing import Any, Dict, List, TypeAlias
+
+MethodConfig: TypeAlias = Dict[str, Dict[str, Any]]
+PipelineStageConfig: TypeAlias = List[MethodConfig]
+PipelineConfig: TypeAlias = List[PipelineStageConfig]
+
+# NOTE: when creating a Pythonic pipeline, please use
+# the function's name "methods_to_list" so it will be
+# found by the loader
+
+
+def methods_to_list() -> PipelineConfig:
+    """Pythonic way to build a list of tasks
+    from which Pipeline can be generated in Httomo.
+    This accompaniments the YAML interface.
+
+    Returns:
+        PipelineConfig: A list of tasks in "full_pipeline_list" that can be executed in Httomo.
+    """
+    full_pipeline_list = []
+    loader = {
+        "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": [None, {"start": 30, "stop": 60}, None],
+            "pad": 0,
+        },
+    }
+    full_pipeline_list.append(loader)
+    method1 = {
+        "method": "find_center_vo",
+        "module_path": "tomopy.recon.rotation",
+        "id": "centering",
+        "parameters": {
+            "ind": "mid",
+            "smin": -50,
+            "smax": 50,
+            "srad": 6,
+            "step": 0.25,
+            "ratio": 0.5,
+            "drop": 20,
+        },
+        "side_outputs": {"cor": "centre_of_rotation"},
+    }
+    full_pipeline_list.append(method1)
+    method2 = {
+        "method": "remove_outlier",
+        "module_path": "tomopy.misc.corr",
+        "parameters": {
+            "dif": 0.1,
+            "size": 3,
+            "axis": 0,
+        },
+    }
+    full_pipeline_list.append(method2)
+    method3 = {
+        "method": "normalize",
+        "module_path": "tomopy.prep.normalize",
+        "parameters": {
+            "cutoff": None,
+        },
+    }
+    full_pipeline_list.append(method3)
+    method4 = {
+        "method": "minus_log",
+        "module_path": "tomopy.prep.normalize",
+        "parameters": {},
+    }
+    full_pipeline_list.append(method4)
+    method5 = {
+        "method": "remove_stripe_fw",
+        "module_path": "tomopy.prep.stripe",
+        "parameters": {
+            "level": None,
+            "wname": "db5",
+            "sigma": 2,
+            "pad": True,
+        },
+    }
+    full_pipeline_list.append(method5)
+    method6 = {
+        "method": "recon",
+        "module_path": "tomopy.recon.algorithm",
+        "parameters": {
+            "center": "${{centering.side_outputs.centre_of_rotation}}",
+            "sinogram_order": False,
+            "algorithm": "gridrec",
+            "init_recon": None,
+        },
+        "save_result": True,
+    }
+    full_pipeline_list.append(method6)
+    method7 = {
+        "method": "median_filter",
+        "module_path": "tomopy.misc.corr",
+        "parameters": {
+            "size": 3,
+            "axis": 0,
+        },
+    }
+    full_pipeline_list.append(method7)
+    method8 = {
+        "method": "save_to_images",
+        "module_path": "httomolib.misc.images",
+        "parameters": {
+            "subfolder_name": "images",
+            "axis": 1,
+            "file_format": "tif",
+            "bits": 8,
+            "perc_range_min": 0.0,
+            "perc_range_max": 100.0,
+            "jpeg_quality": 95,
+        },
+    }
+    full_pipeline_list.append(method8)
+
+    return full_pipeline_list
diff --git a/samples/python_templates/pipeline_cpu3.py b/samples/python_templates/pipeline_cpu3.py
new file mode 100644
index 000000000..018fcd554
--- /dev/null
+++ b/samples/python_templates/pipeline_cpu3.py
@@ -0,0 +1,110 @@
+from typing import Any, Dict, List, TypeAlias
+
+MethodConfig: TypeAlias = Dict[str, Dict[str, Any]]
+PipelineStageConfig: TypeAlias = List[MethodConfig]
+PipelineConfig: TypeAlias = List[PipelineStageConfig]
+
+# NOTE: when creating a Pythonic pipeline, please use
+# the function's name "methods_to_list" so it will be
+# found by the loader
+
+
+def methods_to_list() -> PipelineConfig:
+    """Pythonic way to build a list of tasks
+    from which Pipeline can be generated in Httomo.
+    This accompaniments the YAML interface.
+
+    Returns:
+        PipelineConfig: A list of tasks in "full_pipeline_list" that can be executed in Httomo.
+    """
+    full_pipeline_list = []
+    loader = {
+        "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": [None, None, None],
+            "pad": 0,
+        },
+    }
+    full_pipeline_list.append(loader)
+    method1 = {
+        "method": "find_center_vo",
+        "module_path": "tomopy.recon.rotation",
+        "id": "centering",
+        "parameters": {
+            "ind": "mid",
+            "smin": -50,
+            "smax": 50,
+            "srad": 6,
+            "step": 0.25,
+            "ratio": 0.5,
+            "drop": 20,
+        },
+        "side_outputs": {"cor": "centre_of_rotation"},
+    }
+    full_pipeline_list.append(method1)
+    method2 = {
+        "method": "remove_outlier",
+        "module_path": "tomopy.misc.corr",
+        "parameters": {
+            "dif": 0.1,
+            "size": 3,
+            "axis": 0,
+        },
+    }
+    full_pipeline_list.append(method2)
+    method3 = {
+        "method": "normalize",
+        "module_path": "tomopy.prep.normalize",
+        "parameters": {
+            "cutoff": None,
+        },
+    }
+    full_pipeline_list.append(method3)
+    method4 = {
+        "method": "minus_log",
+        "module_path": "tomopy.prep.normalize",
+        "parameters": {},
+    }
+    full_pipeline_list.append(method4)
+    method5 = {
+        "method": "recon",
+        "module_path": "tomopy.recon.algorithm",
+        "parameters": {
+            "center": "${{centering.side_outputs.centre_of_rotation}}",
+            "sinogram_order": False,
+            "algorithm": "gridrec",
+            "init_recon": None,
+        },
+    }
+    full_pipeline_list.append(method5)
+    method6 = {
+        "method": "calculate_stats",
+        "module_path": "httomo.methods",
+        "id": "statistics",
+        "parameters": {},
+        "side_outputs": {"glob_stats": "glob_stats"},
+    }
+    full_pipeline_list.append(method6)
+    method7 = {
+        "method": "save_to_images",
+        "module_path": "httomolib.misc.images",
+        "parameters": {
+            "subfolder_name": "images",
+            "axis": 1,
+            "file_format": "tif",
+            "bits": 8,
+            "perc_range_min": 0.0,
+            "perc_range_max": 100.0,
+            "jpeg_quality": 95,
+            "glob_stats": "${{statistics.side_outputs.glob_stats}}",
+        },
+    }
+    full_pipeline_list.append(method7)
+
+    return full_pipeline_list
diff --git a/samples/python_templates/pipeline_gpu1.py b/samples/python_templates/pipeline_gpu1.py
new file mode 100644
index 000000000..c68ddb1c5
--- /dev/null
+++ b/samples/python_templates/pipeline_gpu1.py
@@ -0,0 +1,106 @@
+from typing import Any, Dict, List, TypeAlias
+
+MethodConfig: TypeAlias = Dict[str, Dict[str, Any]]
+PipelineStageConfig: TypeAlias = List[MethodConfig]
+PipelineConfig: TypeAlias = List[PipelineStageConfig]
+
+# NOTE: when creating a Pythonic pipeline, please use
+# the function's name "methods_to_list" so it will be
+# found by the loader
+
+
+def methods_to_list() -> PipelineConfig:
+    """Pythonic way to build a list of tasks
+    from which Pipeline can be generated in Httomo.
+    This script accompaniments the YAML pipeline.
+
+    Returns:
+        PipelineConfig: A list of tasks in "full_pipeline_list" that can be executed in Httomo.
+    """
+    full_pipeline_list = []
+    loader = {
+        "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": [None, None, None],
+            "pad": 0,
+        },
+    }
+    full_pipeline_list.append(loader)
+    method1 = {
+        "method": "find_center_vo",
+        "module_path": "httomolibgpu.recon.rotation",
+        "id": "centering",
+        "parameters": {
+            "ind": "mid",
+            "smin": -50,
+            "smax": 50,
+            "srad": 6,
+            "step": 0.25,
+            "ratio": 0.5,
+            "drop": 20,
+        },
+        "side_outputs": {"cor": "centre_of_rotation"},
+    }
+    full_pipeline_list.append(method1)
+    method2 = {
+        "method": "remove_outlier3d",
+        "module_path": "httomolibgpu.misc.corr",
+        "parameters": {
+            "dif": 0.1,
+            "kernel_size": 3,
+        },
+    }
+    full_pipeline_list.append(method2)
+    method3 = {
+        "method": "normalize",
+        "module_path": "httomolibgpu.prep.normalize",
+        "parameters": {
+            "cutoff": 10.0,
+            "minus_log": True,
+            "nonnegativity": False,
+            "remove_nans": False,
+        },
+    }
+    full_pipeline_list.append(method3)
+    method4 = {
+        "method": "remove_stripe_based_sorting",
+        "module_path": "httomolibgpu.prep.stripe",
+        "parameters": {
+            "size": 11,
+            "dim": 1,
+        },
+    }
+    full_pipeline_list.append(method4)
+    method5 = {
+        "method": "FBP",
+        "module_path": "httomolibgpu.recon.algorithm",
+        "parameters": {
+            "center": "${{centering.side_outputs.centre_of_rotation}}",
+            "recon_size": None,
+            "recon_mask_radius": None,
+        },
+        "save_result": True,
+    }
+    full_pipeline_list.append(method5)
+    method6 = {
+        "method": "save_to_images",
+        "module_path": "httomolib.misc.images",
+        "parameters": {
+            "subfolder_name": "images",
+            "axis": 1,
+            "file_format": "tif",
+            "bits": 8,
+            "perc_range_min": 0.0,
+            "perc_range_max": 100.0,
+            "jpeg_quality": 95,
+        },
+    }
+    full_pipeline_list.append(method6)
+
+    return full_pipeline_list
diff --git a/samples/samples_info.txt b/samples/samples_info.txt
new file mode 100755
index 000000000..fe76086f6
--- /dev/null
+++ b/samples/samples_info.txt
@@ -0,0 +1,19 @@
+This needs to be converted to Documentation file where details for every sample are exposed:
+
+pipeline_template_examples:
+  - pipeline_cpu1.yaml: Basic TomoPy (CPU-only) reconstruction pipeline for the classical 180 degrees scan
+  - pipeline_cpu2.yaml: More advanced TomoPy reconstruction pipeline for the classical 180 degrees scan. The result of the data is now saved as hdf5 file in the reconstruction module. 
+  - pipeline_cpu3.yaml: TomoPy-CPU pipeline (180 scan) but using `calculate_stats` module to extract global statistics in order to be passed to `save_to_images` function which uses it for data normalisation when saving images.
+  - pipeline_gpu1.yaml: A basic GPU-driven pipeline which uses functions from the httomolibgpu library.
+  - pipeline_360deg_gpu2: GPU-driven pipeline for 360 data (extract of real data from i12) which estimates CoR and overlap and then stitches the 360 sinogram into the classical 180-degrees sinogram. Reconstructs and saves images. Extensive use of side_outputs and refrencing. 
+
+
+DLS-specific:
+  - 01_diad_pipeline_gpu.yaml: GPU-based pipeline using httomolibgpu for DIAD data. Global statistics and referencing is used. 
+
+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)
+
diff --git a/templates/httomo/1.2/httomo.data.hdf.loaders/standard_tomo.yaml b/templates/httomo/1.2/httomo.data.hdf.loaders/standard_tomo.yaml
new file mode 100644
index 000000000..893e1b423
--- /dev/null
+++ b/templates/httomo/1.2/httomo.data.hdf.loaders/standard_tomo.yaml
@@ -0,0 +1,13 @@
+- method: standard_tomo
+  module_path: httomo.data.hdf.loaders
+  parameters:
+    name: REQUIRED
+    data_path: REQUIRED
+    dimension: REQUIRED
+    preview: REQUIRED
+    pad: REQUIRED
+    image_key_path: null
+    rotation_angles:
+      data_path: /entry1/tomo_entry/data/rotation_angle
+    ignore_darks: false
+    ignore_flats: false
diff --git a/templates/httomo/1.2/httomo.methods/calculate_stats.yaml b/templates/httomo/1.2/httomo.methods/calculate_stats.yaml
new file mode 100644
index 000000000..b865f53b1
--- /dev/null
+++ b/templates/httomo/1.2/httomo.methods/calculate_stats.yaml
@@ -0,0 +1,6 @@
+- method: calculate_stats
+  module_path: httomo.methods
+  parameters: {}
+  id: statistics
+  side_outputs:
+    glob_stats: glob_stats
diff --git a/templates/httomo/httomo.data.hdf.loaders/standard_tomo.yaml b/templates/httomo/httomo.data.hdf.loaders/standard_tomo.yaml
deleted file mode 100644
index 4f184f551..000000000
--- a/templates/httomo/httomo.data.hdf.loaders/standard_tomo.yaml
+++ /dev/null
@@ -1,9 +0,0 @@
-- httomo.data.hdf.loaders:
-    standard_tomo:
-      name: tomo
-      data_path: entry1/tomo_entry/data/data
-      image_key_path: entry1/tomo_entry/instrument/detector/image_key
-      angles_path: /entry1/tomo_entry/data/rotation_angle
-      dimension: 1
-      preview: [None, None, None]
-      pad: 0
diff --git a/templates/httomo/httomo_modules.yaml b/templates/httomo/httomo_modules.yaml
new file mode 100644
index 000000000..a3375c2c3
--- /dev/null
+++ b/templates/httomo/httomo_modules.yaml
@@ -0,0 +1,2 @@
+- httomo.data.hdf.loaders
+- httomo.methods
\ No newline at end of file
diff --git a/templates/httomolib/1.2/httomolib.misc.corr/inpainting_filter3d.yaml b/templates/httomolib/1.2/httomolib.misc.corr/inpainting_filter3d.yaml
index e7c720505..08d1d1f0f 100644
--- a/templates/httomolib/1.2/httomolib.misc.corr/inpainting_filter3d.yaml
+++ b/templates/httomolib/1.2/httomolib.misc.corr/inpainting_filter3d.yaml
@@ -1,8 +1,7 @@
-- httomolib.misc.corr:
-    inpainting_filter3d:
-      data_in: tomo
-      data_out: tomo
-      mask: REQUIRED
-      iter: 3
-      windowsize_half: 5
-      method_type: random
+- 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 9792e3f8d..ff7f70f27 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
@@ -1,11 +1,11 @@
-- httomolib.misc.images:
-    save_to_images:
-      data_in: tomo
-      subfolder_name: images
-      axis: 1
-      file_format: tif
-      bits: 8
-      perc_range_min: 0.0
-      perc_range_max: 100.0
-      jpeg_quality: 95
-      glob_stats: null
+- method: save_to_images
+  module_path: httomolib.misc.images
+  parameters:
+    subfolder_name: images
+    axis: 1
+    file_format: tif
+    bits: 8
+    perc_range_min: 0.0
+    perc_range_max: 100.0
+    jpeg_quality: 95
+    glob_stats: ${{statistics.side_outputs.glob_stats}}
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 8ed1120fc..459aca568 100644
--- a/templates/httomolib/1.2/httomolib.misc.segm/binary_thresholding.yaml
+++ b/templates/httomolib/1.2/httomolib.misc.segm/binary_thresholding.yaml
@@ -1,8 +1,7 @@
-- httomolib.misc.segm:
-    binary_thresholding:
-      data_in: tomo
-      data_out: tomo
-      val_intensity: 0.1
-      otsu: false
-      foreground: true
-      axis: 1
+- method: binary_thresholding
+  module_path: httomolib.misc.segm
+  parameters:
+    val_intensity: 0.1
+    otsu: false
+    foreground: true
+    axis: 1
diff --git a/templates/httomolib/1.2/httomolib.prep.phase/paganin_filter.yaml b/templates/httomolib/1.2/httomolib.prep.phase/paganin_filter.yaml
index e03279fc0..63da87de0 100644
--- a/templates/httomolib/1.2/httomolib.prep.phase/paganin_filter.yaml
+++ b/templates/httomolib/1.2/httomolib.prep.phase/paganin_filter.yaml
@@ -1,8 +1,7 @@
-- httomolib.prep.phase:
-    paganin_filter:
-      data_in: tomo
-      data_out: tomo
-      pixel_size: 0.0001
-      dist: 50.0
-      energy: 53.0
-      alpha: 0.001
+- method: paganin_filter
+  module_path: httomolib.prep.phase
+  parameters:
+    pixel_size: 0.0001
+    dist: 50.0
+    energy: 53.0
+    alpha: 0.001
diff --git a/templates/httomolib/httomolib_modules.yaml b/templates/httomolib/httomolib_modules.yaml
index 55230a207..8fe8d7016 100644
--- a/templates/httomolib/httomolib_modules.yaml
+++ b/templates/httomolib/httomolib_modules.yaml
@@ -1,4 +1,4 @@
 - httomolib.misc.corr
 - httomolib.misc.images
 - httomolib.misc.segm
-- httomolib.prep.phase
\ No newline at end of file
+- 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_filter3d.yaml
index 1e2e5a441..3e44f87f3 100644
--- a/templates/httomolibgpu/1.2/httomolibgpu.misc.corr/median_filter3d.yaml
+++ b/templates/httomolibgpu/1.2/httomolibgpu.misc.corr/median_filter3d.yaml
@@ -1,6 +1,5 @@
-- httomolibgpu.misc.corr:
-    median_filter3d:
-      data_in: tomo
-      data_out: tomo
-      kernel_size: 3
-      dif: 0.0
+- method: median_filter3d
+  module_path: httomolibgpu.misc.corr
+  parameters:
+    kernel_size: 3
+    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_outlier3d.yaml
index 3306b96d6..066225b95 100644
--- a/templates/httomolibgpu/1.2/httomolibgpu.misc.corr/remove_outlier3d.yaml
+++ b/templates/httomolibgpu/1.2/httomolibgpu.misc.corr/remove_outlier3d.yaml
@@ -1,6 +1,5 @@
-- httomolibgpu.misc.corr:
-    remove_outlier3d:
-      data_in: tomo
-      data_out: tomo
-      kernel_size: 3
-      dif: 0.1
+- method: remove_outlier3d
+  module_path: httomolibgpu.misc.corr
+  parameters:
+    kernel_size: 3
+    dif: 0.1
diff --git a/templates/httomolibgpu/1.2/httomolibgpu.misc.morph/sino_360_to_180.yaml b/templates/httomolibgpu/1.2/httomolibgpu.misc.morph/sino_360_to_180.yaml
index b3d2bfd72..8434e052f 100644
--- a/templates/httomolibgpu/1.2/httomolibgpu.misc.morph/sino_360_to_180.yaml
+++ b/templates/httomolibgpu/1.2/httomolibgpu.misc.morph/sino_360_to_180.yaml
@@ -1,6 +1,5 @@
-- httomolibgpu.misc.morph:
-    sino_360_to_180:
-      data_in: tomo
-      data_out: tomo
-      overlap: 0
-      rotation: left
+- method: sino_360_to_180
+  module_path: httomolibgpu.misc.morph
+  parameters:
+    overlap: ${{centering.side_outputs.overlap}}
+    rotation: left
diff --git a/templates/httomolibgpu/1.2/httomolibgpu.prep.alignment/distortion_correction_proj.yaml b/templates/httomolibgpu/1.2/httomolibgpu.prep.alignment/distortion_correction_proj.yaml
new file mode 100644
index 000000000..869ee8c53
--- /dev/null
+++ b/templates/httomolibgpu/1.2/httomolibgpu.prep.alignment/distortion_correction_proj.yaml
@@ -0,0 +1,9 @@
+- method: distortion_correction_proj
+  module_path: httomolibgpu.prep.alignment
+  parameters:
+    metadata_path: REQUIRED
+    preview: REQUIRED
+    center_from_left: null
+    center_from_top: null
+    polynomial_coeffs: null
+    crop: 0
diff --git a/templates/httomolibgpu/1.2/httomolibgpu.prep.alignment/distortion_correction_proj_discorpy.yaml b/templates/httomolibgpu/1.2/httomolibgpu.prep.alignment/distortion_correction_proj_discorpy.yaml
index c81fef45f..5e31d25e1 100644
--- a/templates/httomolibgpu/1.2/httomolibgpu.prep.alignment/distortion_correction_proj_discorpy.yaml
+++ b/templates/httomolibgpu/1.2/httomolibgpu.prep.alignment/distortion_correction_proj_discorpy.yaml
@@ -1,8 +1,7 @@
-- httomolibgpu.prep.alignment:
-    distortion_correction_proj_discorpy:
-      data_in: tomo
-      data_out: tomo
-      metadata_path: REQUIRED
-      preview: REQUIRED
-      order: 1
-      mode: reflect
+- method: distortion_correction_proj_discorpy
+  module_path: httomolibgpu.prep.alignment
+  parameters:
+    metadata_path: REQUIRED
+    preview: REQUIRED
+    order: 1
+    mode: reflect
diff --git a/templates/httomolibgpu/1.2/httomolibgpu.prep.normalize/normalize.yaml b/templates/httomolibgpu/1.2/httomolibgpu.prep.normalize/normalize.yaml
index a53981cdc..66609c793 100644
--- a/templates/httomolibgpu/1.2/httomolibgpu.prep.normalize/normalize.yaml
+++ b/templates/httomolibgpu/1.2/httomolibgpu.prep.normalize/normalize.yaml
@@ -1,8 +1,7 @@
-- httomolibgpu.prep.normalize:
-    normalize:
-      data_in: tomo
-      data_out: tomo
-      cutoff: 10.0
-      minus_log: false
-      nonnegativity: false
-      remove_nans: false
+- method: normalize
+  module_path: httomolibgpu.prep.normalize
+  parameters:
+    cutoff: 10.0
+    minus_log: false
+    nonnegativity: false
+    remove_nans: false
diff --git a/templates/httomolibgpu/1.2/httomolibgpu.prep.phase/fresnel_filter.yaml b/templates/httomolibgpu/1.2/httomolibgpu.prep.phase/fresnel_filter.yaml
new file mode 100644
index 000000000..437bc9e61
--- /dev/null
+++ b/templates/httomolibgpu/1.2/httomolibgpu.prep.phase/fresnel_filter.yaml
@@ -0,0 +1,7 @@
+- method: fresnel_filter
+  module_path: httomolibgpu.prep.phase
+  parameters:
+    mat: REQUIRED
+    pattern: REQUIRED
+    ratio: REQUIRED
+    apply_log: true
diff --git a/templates/httomolibgpu/1.2/httomolibgpu.prep.phase/paganin_filter.yaml b/templates/httomolibgpu/1.2/httomolibgpu.prep.phase/paganin_filter.yaml
new file mode 100644
index 000000000..ef65be0e5
--- /dev/null
+++ b/templates/httomolibgpu/1.2/httomolibgpu.prep.phase/paganin_filter.yaml
@@ -0,0 +1,11 @@
+- method: paganin_filter
+  module_path: httomolibgpu.prep.phase
+  parameters:
+    ratio: 250.0
+    energy: 53.0
+    distance: 1.0
+    resolution: 1.28
+    pad_y: 100
+    pad_x: 100
+    pad_method: edge
+    increment: 0.0
diff --git a/templates/httomolibgpu/1.2/httomolibgpu.prep.phase/paganin_filter2.yaml b/templates/httomolibgpu/1.2/httomolibgpu.prep.phase/paganin_filter2.yaml
new file mode 100644
index 000000000..cffc728f6
--- /dev/null
+++ b/templates/httomolibgpu/1.2/httomolibgpu.prep.phase/paganin_filter2.yaml
@@ -0,0 +1,7 @@
+- method: paganin_filter2
+  module_path: httomolibgpu.prep.phase
+  parameters:
+    pixel_size: 0.0001
+    dist: 50.0
+    energy: 53.0
+    alpha: 0.001
diff --git a/templates/httomolibgpu/1.2/httomolibgpu.prep.phase/paganin_filter_savu.yaml b/templates/httomolibgpu/1.2/httomolibgpu.prep.phase/paganin_filter_savu.yaml
index 02ca3dd30..1d7a5027a 100644
--- a/templates/httomolibgpu/1.2/httomolibgpu.prep.phase/paganin_filter_savu.yaml
+++ b/templates/httomolibgpu/1.2/httomolibgpu.prep.phase/paganin_filter_savu.yaml
@@ -1,12 +1,11 @@
-- httomolibgpu.prep.phase:
-    paganin_filter_savu:
-      data_in: tomo
-      data_out: tomo
-      ratio: 250.0
-      energy: 53.0
-      distance: 1.0
-      resolution: 1.28
-      pad_y: 100
-      pad_x: 100
-      pad_method: edge
-      increment: 0.0
+- method: paganin_filter_savu
+  module_path: httomolibgpu.prep.phase
+  parameters:
+    ratio: 250.0
+    energy: 53.0
+    distance: 1.0
+    resolution: 1.28
+    pad_y: 100
+    pad_x: 100
+    pad_method: edge
+    increment: 0.0
diff --git a/templates/httomolibgpu/1.2/httomolibgpu.prep.phase/paganin_filter_tomopy.yaml b/templates/httomolibgpu/1.2/httomolibgpu.prep.phase/paganin_filter_tomopy.yaml
index fd877370c..7752f1e7f 100644
--- a/templates/httomolibgpu/1.2/httomolibgpu.prep.phase/paganin_filter_tomopy.yaml
+++ b/templates/httomolibgpu/1.2/httomolibgpu.prep.phase/paganin_filter_tomopy.yaml
@@ -1,8 +1,7 @@
-- httomolibgpu.prep.phase:
-    paganin_filter_tomopy:
-      data_in: tomo
-      data_out: tomo
-      pixel_size: 0.0001
-      dist: 50.0
-      energy: 53.0
-      alpha: 0.001
+- method: paganin_filter_tomopy
+  module_path: httomolibgpu.prep.phase
+  parameters:
+    pixel_size: 0.0001
+    dist: 50.0
+    energy: 53.0
+    alpha: 0.001
diff --git a/templates/httomolibgpu/1.2/httomolibgpu.prep.stripe/remove_all_stripe.yaml b/templates/httomolibgpu/1.2/httomolibgpu.prep.stripe/remove_all_stripe.yaml
new file mode 100644
index 000000000..5cfa82c1e
--- /dev/null
+++ b/templates/httomolibgpu/1.2/httomolibgpu.prep.stripe/remove_all_stripe.yaml
@@ -0,0 +1,7 @@
+- method: remove_all_stripe
+  module_path: httomolibgpu.prep.stripe
+  parameters:
+    snr: 3.0
+    la_size: 61
+    sm_size: 21
+    dim: 1
diff --git a/templates/httomolibgpu/1.2/httomolibgpu.prep.stripe/remove_stripe_based_sorting.yaml b/templates/httomolibgpu/1.2/httomolibgpu.prep.stripe/remove_stripe_based_sorting.yaml
index 5650e47d3..a113f74f8 100644
--- a/templates/httomolibgpu/1.2/httomolibgpu.prep.stripe/remove_stripe_based_sorting.yaml
+++ b/templates/httomolibgpu/1.2/httomolibgpu.prep.stripe/remove_stripe_based_sorting.yaml
@@ -1,6 +1,5 @@
-- httomolibgpu.prep.stripe:
-    remove_stripe_based_sorting:
-      data_in: tomo
-      data_out: tomo
-      size: 11
-      dim: 1
+- method: remove_stripe_based_sorting
+  module_path: httomolibgpu.prep.stripe
+  parameters:
+    size: 11
+    dim: 1
diff --git a/templates/httomolibgpu/1.2/httomolibgpu.prep.stripe/remove_stripe_ti.yaml b/templates/httomolibgpu/1.2/httomolibgpu.prep.stripe/remove_stripe_ti.yaml
index 390abeda3..71332c0dc 100644
--- a/templates/httomolibgpu/1.2/httomolibgpu.prep.stripe/remove_stripe_ti.yaml
+++ b/templates/httomolibgpu/1.2/httomolibgpu.prep.stripe/remove_stripe_ti.yaml
@@ -1,5 +1,4 @@
-- httomolibgpu.prep.stripe:
-    remove_stripe_ti:
-      data_in: tomo
-      data_out: tomo
-      beta: 0.1
+- method: remove_stripe_ti
+  module_path: httomolibgpu.prep.stripe
+  parameters:
+    beta: 0.1
diff --git a/templates/httomolibgpu/1.2/httomolibgpu.recon.algorithm/CGLS.yaml b/templates/httomolibgpu/1.2/httomolibgpu.recon.algorithm/CGLS.yaml
index ba20726b8..b87272e6b 100644
--- a/templates/httomolibgpu/1.2/httomolibgpu.recon.algorithm/CGLS.yaml
+++ b/templates/httomolibgpu/1.2/httomolibgpu.recon.algorithm/CGLS.yaml
@@ -1,9 +1,7 @@
-- httomolibgpu.recon.algorithm:
-    CGLS:
-      data_in: tomo
-      data_out: tomo
-      center: null
-      recon_size: null
-      iterations: 20
-      nonnegativity: true
-      recon_mask_radius: null
+- method: CGLS
+  module_path: httomolibgpu.recon.algorithm
+  parameters:
+    center: ${{centering.side_outputs.centre_of_rotation}}
+    recon_size: null
+    iterations: 20
+    nonnegativity: true
diff --git a/templates/httomolibgpu/1.2/httomolibgpu.recon.algorithm/FBP.yaml b/templates/httomolibgpu/1.2/httomolibgpu.recon.algorithm/FBP.yaml
index 2ade9ed06..6798cfcc3 100644
--- a/templates/httomolibgpu/1.2/httomolibgpu.recon.algorithm/FBP.yaml
+++ b/templates/httomolibgpu/1.2/httomolibgpu.recon.algorithm/FBP.yaml
@@ -1,7 +1,6 @@
-- httomolibgpu.recon.algorithm:
-    FBP:
-      data_in: tomo
-      data_out: tomo
-      center: null
-      recon_size: null
-      recon_mask_radius: null
+- method: FBP
+  module_path: httomolibgpu.recon.algorithm
+  parameters:
+    center: ${{centering.side_outputs.centre_of_rotation}}
+    recon_size: null
+    recon_mask_radius: null
diff --git a/templates/httomolibgpu/1.2/httomolibgpu.recon.algorithm/SIRT.yaml b/templates/httomolibgpu/1.2/httomolibgpu.recon.algorithm/SIRT.yaml
index 00d57e811..ada743094 100644
--- a/templates/httomolibgpu/1.2/httomolibgpu.recon.algorithm/SIRT.yaml
+++ b/templates/httomolibgpu/1.2/httomolibgpu.recon.algorithm/SIRT.yaml
@@ -1,9 +1,7 @@
-- httomolibgpu.recon.algorithm:
-    SIRT:
-      data_in: tomo
-      data_out: tomo
-      center: null
-      recon_size: null
-      iterations: 300
-      nonnegativity: true
-      recon_mask_radius: null
+- method: SIRT
+  module_path: httomolibgpu.recon.algorithm
+  parameters:
+    center: ${{centering.side_outputs.centre_of_rotation}}
+    recon_size: null
+    iterations: 300
+    nonnegativity: true
diff --git a/templates/httomolibgpu/1.2/httomolibgpu.recon.rotation/find_center_360.yaml b/templates/httomolibgpu/1.2/httomolibgpu.recon.rotation/find_center_360.yaml
index 3fade9970..59435bf2f 100644
--- a/templates/httomolibgpu/1.2/httomolibgpu.recon.rotation/find_center_360.yaml
+++ b/templates/httomolibgpu/1.2/httomolibgpu.recon.rotation/find_center_360.yaml
@@ -1,14 +1,15 @@
-- httomolibgpu.recon.rotation:
-    find_center_360:
-      data_in: tomo
-      data_out:
-      - cor
-      - overlap
-      - side
-      - overlap_position
-      ind: null
-      win_width: 10
-      side: null
-      denoise: true
-      norm: false
-      use_overlap: false
+- method: find_center_360
+  module_path: httomolibgpu.recon.rotation
+  parameters:
+    ind: null
+    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
diff --git a/templates/httomolibgpu/1.2/httomolibgpu.recon.rotation/find_center_vo.yaml b/templates/httomolibgpu/1.2/httomolibgpu.recon.rotation/find_center_vo.yaml
index dcdfd9660..fc5739790 100644
--- a/templates/httomolibgpu/1.2/httomolibgpu.recon.rotation/find_center_vo.yaml
+++ b/templates/httomolibgpu/1.2/httomolibgpu.recon.rotation/find_center_vo.yaml
@@ -1,11 +1,13 @@
-- httomolibgpu.recon.rotation:
-    find_center_vo:
-      data_in: tomo
-      data_out: cor
-      ind: null
-      smin: -50
-      smax: 50
-      srad: 6.0
-      step: 0.25
-      ratio: 0.5
-      drop: 20
+- method: find_center_vo
+  module_path: httomolibgpu.recon.rotation
+  parameters:
+    ind: null
+    smin: -50
+    smax: 50
+    srad: 6.0
+    step: 0.25
+    ratio: 0.5
+    drop: 20
+  id: centering
+  side_outputs:
+    cor: centre_of_rotation
diff --git a/templates/tomopy/1.14/tomopy.misc.corr/adjust_range.yaml b/templates/tomopy/1.14/tomopy.misc.corr/adjust_range.yaml
index be4321123..f02934fbc 100644
--- a/templates/tomopy/1.14/tomopy.misc.corr/adjust_range.yaml
+++ b/templates/tomopy/1.14/tomopy.misc.corr/adjust_range.yaml
@@ -1,6 +1,5 @@
-- tomopy.misc.corr:
-    adjust_range:
-      data_in: tomo
-      data_out: tomo
-      dmin: null
-      dmax: null
+- method: adjust_range
+  module_path: tomopy.misc.corr
+  parameters:
+    dmin: null
+    dmax: null
diff --git a/templates/tomopy/1.14/tomopy.misc.corr/circ_mask.yaml b/templates/tomopy/1.14/tomopy.misc.corr/circ_mask.yaml
index ab7778311..401836769 100644
--- a/templates/tomopy/1.14/tomopy.misc.corr/circ_mask.yaml
+++ b/templates/tomopy/1.14/tomopy.misc.corr/circ_mask.yaml
@@ -1,7 +1,6 @@
-- tomopy.misc.corr:
-    circ_mask:
-      data_in: tomo
-      data_out: tomo
-      axis: REQUIRED
-      ratio: 1
-      val: 0.0
+- method: circ_mask
+  module_path: tomopy.misc.corr
+  parameters:
+    axis: REQUIRED
+    ratio: 1
+    val: 0.0
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
new file mode 100644
index 000000000..5a1ca23c6
--- /dev/null
+++ b/templates/tomopy/1.14/tomopy.misc.corr/enhance_projs_aps_1id.yaml
@@ -0,0 +1,5 @@
+- 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 e48937914..25189f57d 100644
--- a/templates/tomopy/1.14/tomopy.misc.corr/gaussian_filter.yaml
+++ b/templates/tomopy/1.14/tomopy.misc.corr/gaussian_filter.yaml
@@ -1,7 +1,6 @@
-- tomopy.misc.corr:
-    gaussian_filter:
-      data_in: tomo
-      data_out: tomo
-      sigma: 3
-      order: 0
-      axis: 0
+- method: gaussian_filter
+  module_path: tomopy.misc.corr
+  parameters:
+    sigma: 3
+    order: 0
+    axis: 0
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 40ff893f4..4fa945a0d 100644
--- a/templates/tomopy/1.14/tomopy.misc.corr/median_filter.yaml
+++ b/templates/tomopy/1.14/tomopy.misc.corr/median_filter.yaml
@@ -1,6 +1,5 @@
-- tomopy.misc.corr:
-    median_filter:
-      data_in: tomo
-      data_out: tomo
-      size: 3
-      axis: 0
+- method: median_filter
+  module_path: tomopy.misc.corr
+  parameters:
+    size: 3
+    axis: 0
diff --git a/templates/tomopy/1.14/tomopy.misc.corr/median_filter3d.yaml b/templates/tomopy/1.14/tomopy.misc.corr/median_filter3d.yaml
index 510892112..a469bac13 100644
--- a/templates/tomopy/1.14/tomopy.misc.corr/median_filter3d.yaml
+++ b/templates/tomopy/1.14/tomopy.misc.corr/median_filter3d.yaml
@@ -1,5 +1,4 @@
-- tomopy.misc.corr:
-    median_filter3d:
-      data_in: tomo
-      data_out: tomo
-      size: 3
+- method: median_filter3d
+  module_path: tomopy.misc.corr
+  parameters:
+    size: 3
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
new file mode 100644
index 000000000..b24512c35
--- /dev/null
+++ b/templates/tomopy/1.14/tomopy.misc.corr/median_filter_cuda.yaml
@@ -0,0 +1,5 @@
+- method: median_filter_cuda
+  module_path: tomopy.misc.corr
+  parameters:
+    size: 3
+    axis: 0
diff --git a/templates/tomopy/1.14/tomopy.misc.corr/median_filter_nonfinite.yaml b/templates/tomopy/1.14/tomopy.misc.corr/median_filter_nonfinite.yaml
index 04bf2a79d..6afcf75f6 100644
--- a/templates/tomopy/1.14/tomopy.misc.corr/median_filter_nonfinite.yaml
+++ b/templates/tomopy/1.14/tomopy.misc.corr/median_filter_nonfinite.yaml
@@ -1,6 +1,5 @@
-- tomopy.misc.corr:
-    median_filter_nonfinite:
-      data_in: tomo
-      data_out: tomo
-      size: 3
-      callback: null
+- method: median_filter_nonfinite
+  module_path: tomopy.misc.corr
+  parameters:
+    size: 3
+    callback: null
diff --git a/templates/tomopy/1.14/tomopy.misc.corr/remove_nan.yaml b/templates/tomopy/1.14/tomopy.misc.corr/remove_nan.yaml
index 6d20f8042..ed802d399 100644
--- a/templates/tomopy/1.14/tomopy.misc.corr/remove_nan.yaml
+++ b/templates/tomopy/1.14/tomopy.misc.corr/remove_nan.yaml
@@ -1,5 +1,4 @@
-- tomopy.misc.corr:
-    remove_nan:
-      data_in: tomo
-      data_out: tomo
-      val: 0.0
+- method: remove_nan
+  module_path: tomopy.misc.corr
+  parameters:
+    val: 0.0
diff --git a/templates/tomopy/1.14/tomopy.misc.corr/remove_neg.yaml b/templates/tomopy/1.14/tomopy.misc.corr/remove_neg.yaml
index ac58d55fc..10fb6d5b6 100644
--- a/templates/tomopy/1.14/tomopy.misc.corr/remove_neg.yaml
+++ b/templates/tomopy/1.14/tomopy.misc.corr/remove_neg.yaml
@@ -1,5 +1,4 @@
-- tomopy.misc.corr:
-    remove_neg:
-      data_in: tomo
-      data_out: tomo
-      val: 0.0
+- method: remove_neg
+  module_path: tomopy.misc.corr
+  parameters:
+    val: 0.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 f092d6d7a..bcc85c2ad 100644
--- a/templates/tomopy/1.14/tomopy.misc.corr/remove_outlier.yaml
+++ b/templates/tomopy/1.14/tomopy.misc.corr/remove_outlier.yaml
@@ -1,7 +1,6 @@
-- tomopy.misc.corr:
-    remove_outlier:
-      data_in: tomo
-      data_out: tomo
-      dif: REQUIRED
-      size: 3
-      axis: 0
+- method: remove_outlier
+  module_path: tomopy.misc.corr
+  parameters:
+    dif: REQUIRED
+    size: 3
+    axis: 0
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 4e961e1de..2606001c3 100644
--- a/templates/tomopy/1.14/tomopy.misc.corr/remove_outlier1d.yaml
+++ b/templates/tomopy/1.14/tomopy.misc.corr/remove_outlier1d.yaml
@@ -1,7 +1,6 @@
-- tomopy.misc.corr:
-    remove_outlier1d:
-      data_in: tomo
-      data_out: tomo
-      dif: REQUIRED
-      size: 3
-      axis: 0
+- method: remove_outlier1d
+  module_path: tomopy.misc.corr
+  parameters:
+    dif: REQUIRED
+    size: 3
+    axis: 0
diff --git a/templates/tomopy/1.14/tomopy.misc.corr/remove_outlier3d.yaml b/templates/tomopy/1.14/tomopy.misc.corr/remove_outlier3d.yaml
index 748f4fa1c..4123734fd 100644
--- a/templates/tomopy/1.14/tomopy.misc.corr/remove_outlier3d.yaml
+++ b/templates/tomopy/1.14/tomopy.misc.corr/remove_outlier3d.yaml
@@ -1,6 +1,5 @@
-- tomopy.misc.corr:
-    remove_outlier3d:
-      data_in: tomo
-      data_out: tomo
-      dif: REQUIRED
-      size: 3
+- method: remove_outlier3d
+  module_path: tomopy.misc.corr
+  parameters:
+    dif: REQUIRED
+    size: 3
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
new file mode 100644
index 000000000..4658d8f37
--- /dev/null
+++ b/templates/tomopy/1.14/tomopy.misc.corr/remove_outlier_cuda.yaml
@@ -0,0 +1,6 @@
+- 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/remove_ring.yaml b/templates/tomopy/1.14/tomopy.misc.corr/remove_ring.yaml
index 6ec0ef981..107c63552 100644
--- a/templates/tomopy/1.14/tomopy.misc.corr/remove_ring.yaml
+++ b/templates/tomopy/1.14/tomopy.misc.corr/remove_ring.yaml
@@ -1,13 +1,12 @@
-- tomopy.misc.corr:
-    remove_ring:
-      data_in: tomo
-      data_out: tomo
-      rec: REQUIRED
-      center_x: null
-      center_y: null
-      thresh: 300.0
-      thresh_max: 300.0
-      thresh_min: -100.0
-      theta_min: 30
-      rwidth: 30
-      int_mode: WRAP
+- method: remove_ring
+  module_path: tomopy.misc.corr
+  parameters:
+    rec: REQUIRED
+    center_x: null
+    center_y: null
+    thresh: 300.0
+    thresh_max: 300.0
+    thresh_min: -100.0
+    theta_min: 30
+    rwidth: 30
+    int_mode: WRAP
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 05425d850..286bc165a 100644
--- a/templates/tomopy/1.14/tomopy.misc.corr/sobel_filter.yaml
+++ b/templates/tomopy/1.14/tomopy.misc.corr/sobel_filter.yaml
@@ -1,5 +1,4 @@
-- tomopy.misc.corr:
-    sobel_filter:
-      data_in: tomo
-      data_out: tomo
-      axis: 0
+- method: sobel_filter
+  module_path: tomopy.misc.corr
+  parameters:
+    axis: 0
diff --git a/templates/tomopy/1.14/tomopy.misc.morph/downsample.yaml b/templates/tomopy/1.14/tomopy.misc.morph/downsample.yaml
index bf62ebb65..596b18ad7 100644
--- a/templates/tomopy/1.14/tomopy.misc.morph/downsample.yaml
+++ b/templates/tomopy/1.14/tomopy.misc.morph/downsample.yaml
@@ -1,6 +1,5 @@
-- tomopy.misc.morph:
-    downsample:
-      data_in: tomo
-      data_out: tomo
-      level: 1
-      axis: 2
+- method: downsample
+  module_path: tomopy.misc.morph
+  parameters:
+    level: 1
+    axis: 2
diff --git a/templates/tomopy/1.14/tomopy.misc.morph/pad.yaml b/templates/tomopy/1.14/tomopy.misc.morph/pad.yaml
index 222b0281b..44572c480 100644
--- a/templates/tomopy/1.14/tomopy.misc.morph/pad.yaml
+++ b/templates/tomopy/1.14/tomopy.misc.morph/pad.yaml
@@ -1,8 +1,7 @@
-- tomopy.misc.morph:
-    pad:
-      data_in: tomo
-      data_out: tomo
-      axis: REQUIRED
-      npad: null
-      mode: constant
-      '#additional parameters': AVAILABLE
+- method: pad
+  module_path: tomopy.misc.morph
+  parameters:
+    axis: REQUIRED
+    npad: null
+    mode: constant
+    '#additional parameters': AVAILABLE
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
new file mode 100644
index 000000000..ff5b71be7
--- /dev/null
+++ b/templates/tomopy/1.14/tomopy.misc.morph/sino_360_t0_180.yaml
@@ -0,0 +1,5 @@
+- 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 5c4969ef4..14f6e9f26 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,6 +1,5 @@
-- tomopy.misc.morph:
-    sino_360_to_180:
-      data_in: tomo
-      data_out: tomo
-      overlap: 0
-      rotation: left
+- method: sino_360_to_180
+  module_path: tomopy.misc.morph
+  parameters:
+    overlap: 0
+    rotation: left
diff --git a/templates/tomopy/1.14/tomopy.misc.morph/trim_sinogram.yaml b/templates/tomopy/1.14/tomopy.misc.morph/trim_sinogram.yaml
index 4312773ec..f020e2ec3 100644
--- a/templates/tomopy/1.14/tomopy.misc.morph/trim_sinogram.yaml
+++ b/templates/tomopy/1.14/tomopy.misc.morph/trim_sinogram.yaml
@@ -1,8 +1,7 @@
-- tomopy.misc.morph:
-    trim_sinogram:
-      data_in: tomo
-      data_out: tomo
-      center: REQUIRED
-      x: REQUIRED
-      y: REQUIRED
-      diameter: REQUIRED
+- method: trim_sinogram
+  module_path: tomopy.misc.morph
+  parameters:
+    center: REQUIRED
+    x: REQUIRED
+    y: REQUIRED
+    diameter: REQUIRED
diff --git a/templates/tomopy/1.14/tomopy.misc.morph/upsample.yaml b/templates/tomopy/1.14/tomopy.misc.morph/upsample.yaml
index 92afa4af2..b27b19f24 100644
--- a/templates/tomopy/1.14/tomopy.misc.morph/upsample.yaml
+++ b/templates/tomopy/1.14/tomopy.misc.morph/upsample.yaml
@@ -1,6 +1,5 @@
-- tomopy.misc.morph:
-    upsample:
-      data_in: tomo
-      data_out: tomo
-      level: 1
-      axis: 2
+- method: upsample
+  module_path: tomopy.misc.morph
+  parameters:
+    level: 1
+    axis: 2
diff --git a/templates/tomopy/1.14/tomopy.prep.alignment/add_jitter.yaml b/templates/tomopy/1.14/tomopy.prep.alignment/add_jitter.yaml
index c43ff4a9e..f7b9821e6 100644
--- a/templates/tomopy/1.14/tomopy.prep.alignment/add_jitter.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.alignment/add_jitter.yaml
@@ -1,6 +1,5 @@
-- tomopy.prep.alignment:
-    add_jitter:
-      data_in: tomo
-      data_out: tomo
-      low: 0
-      high: 1
+- method: add_jitter
+  module_path: tomopy.prep.alignment
+  parameters:
+    low: 0
+    high: 1
diff --git a/templates/tomopy/1.14/tomopy.prep.alignment/add_noise.yaml b/templates/tomopy/1.14/tomopy.prep.alignment/add_noise.yaml
index c7b6a4f77..350e13c9f 100644
--- a/templates/tomopy/1.14/tomopy.prep.alignment/add_noise.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.alignment/add_noise.yaml
@@ -1,5 +1,4 @@
-- tomopy.prep.alignment:
-    add_noise:
-      data_in: tomo
-      data_out: tomo
-      ratio: 0.05
+- method: add_noise
+  module_path: tomopy.prep.alignment
+  parameters:
+    ratio: 0.05
diff --git a/templates/tomopy/1.14/tomopy.prep.alignment/align_joint.yaml b/templates/tomopy/1.14/tomopy.prep.alignment/align_joint.yaml
index 21bb95bdd..35d8f51f4 100644
--- a/templates/tomopy/1.14/tomopy.prep.alignment/align_joint.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.alignment/align_joint.yaml
@@ -1,17 +1,16 @@
-- tomopy.prep.alignment:
-    align_joint:
-      data_in: tomo
-      data_out: tomo
-      fdir: .
-      iters: 10
-      pad: !!python/tuple
-      - 0
-      - 0
-      blur: true
-      center: null
-      algorithm: sirt
-      upsample_factor: 10
-      rin: 0.5
-      rout: 0.8
-      save: false
-      debug: true
+- method: align_joint
+  module_path: tomopy.prep.alignment
+  parameters:
+    fdir: ./
+    iters: 10
+    pad: !!python/tuple
+    - 0
+    - 0
+    blur: true
+    center: ${{centering.side_outputs.centre_of_rotation}}
+    algorithm: sirt
+    upsample_factor: 10
+    rin: 0.5
+    rout: 0.8
+    save: false
+    debug: true
diff --git a/templates/tomopy/1.14/tomopy.prep.alignment/align_seq.yaml b/templates/tomopy/1.14/tomopy.prep.alignment/align_seq.yaml
index 4b05842da..8c4d11c8c 100644
--- a/templates/tomopy/1.14/tomopy.prep.alignment/align_seq.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.alignment/align_seq.yaml
@@ -1,17 +1,16 @@
-- tomopy.prep.alignment:
-    align_seq:
-      data_in: tomo
-      data_out: tomo
-      fdir: .
-      iters: 10
-      pad: !!python/tuple
-      - 0
-      - 0
-      blur: true
-      center: null
-      algorithm: sirt
-      upsample_factor: 10
-      rin: 0.5
-      rout: 0.8
-      save: false
-      debug: true
+- method: align_seq
+  module_path: tomopy.prep.alignment
+  parameters:
+    fdir: .
+    iters: 10
+    pad: !!python/tuple
+    - 0
+    - 0
+    blur: true
+    center: ${{centering.side_outputs.centre_of_rotation}}
+    algorithm: sirt
+    upsample_factor: 10
+    rin: 0.5
+    rout: 0.8
+    save: false
+    debug: true
diff --git a/templates/tomopy/1.14/tomopy.prep.alignment/blur_edges.yaml b/templates/tomopy/1.14/tomopy.prep.alignment/blur_edges.yaml
index a4919fabe..57b2ace2b 100644
--- a/templates/tomopy/1.14/tomopy.prep.alignment/blur_edges.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.alignment/blur_edges.yaml
@@ -1,6 +1,5 @@
-- tomopy.prep.alignment:
-    blur_edges:
-      data_in: tomo
-      data_out: tomo
-      low: 0
-      high: 0.8
+- method: blur_edges
+  module_path: tomopy.prep.alignment
+  parameters:
+    low: 0
+    high: 0.8
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
new file mode 100644
index 000000000..41b84453a
--- /dev/null
+++ b/templates/tomopy/1.14/tomopy.prep.alignment/calc_slit_box_aps_1id.yaml
@@ -0,0 +1,9 @@
+- 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/distortion_correction_proj.yaml b/templates/tomopy/1.14/tomopy.prep.alignment/distortion_correction_proj.yaml
index 86b1b4d58..6dec583e4 100644
--- a/templates/tomopy/1.14/tomopy.prep.alignment/distortion_correction_proj.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.alignment/distortion_correction_proj.yaml
@@ -1,7 +1,6 @@
-- tomopy.prep.alignment:
-    distortion_correction_proj:
-      data_in: tomo
-      data_out: tomo
-      xcenter: REQUIRED
-      ycenter: REQUIRED
-      list_fact: REQUIRED
+- method: distortion_correction_proj
+  module_path: tomopy.prep.alignment
+  parameters:
+    xcenter: REQUIRED
+    ycenter: REQUIRED
+    list_fact: REQUIRED
diff --git a/templates/tomopy/1.14/tomopy.prep.alignment/distortion_correction_sino.yaml b/templates/tomopy/1.14/tomopy.prep.alignment/distortion_correction_sino.yaml
index 562db24d2..d8a4ae49f 100644
--- a/templates/tomopy/1.14/tomopy.prep.alignment/distortion_correction_sino.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.alignment/distortion_correction_sino.yaml
@@ -1,8 +1,7 @@
-- tomopy.prep.alignment:
-    distortion_correction_sino:
-      data_in: tomo
-      data_out: tomo
-      ind: REQUIRED
-      xcenter: REQUIRED
-      ycenter: REQUIRED
-      list_fact: REQUIRED
+- method: distortion_correction_sino
+  module_path: tomopy.prep.alignment
+  parameters:
+    ind: REQUIRED
+    xcenter: REQUIRED
+    ycenter: REQUIRED
+    list_fact: REQUIRED
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
new file mode 100644
index 000000000..437baa475
--- /dev/null
+++ b/templates/tomopy/1.14/tomopy.prep.alignment/find_slits_corners_aps_1id.yaml
@@ -0,0 +1,7 @@
+- 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
new file mode 100644
index 000000000..b5fa275b1
--- /dev/null
+++ b/templates/tomopy/1.14/tomopy.prep.alignment/load_distortion_coefs.yaml
@@ -0,0 +1,4 @@
+- 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
new file mode 100644
index 000000000..73f25487f
--- /dev/null
+++ b/templates/tomopy/1.14/tomopy.prep.alignment/remove_slits_aps_1id.yaml
@@ -0,0 +1,10 @@
+- 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/scale.yaml b/templates/tomopy/1.14/tomopy.prep.alignment/scale.yaml
index 4f650fe43..7a628485b 100644
--- a/templates/tomopy/1.14/tomopy.prep.alignment/scale.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.alignment/scale.yaml
@@ -1,4 +1,3 @@
-- tomopy.prep.alignment:
-    scale:
-      data_in: tomo
-      data_out: tomo
+- method: scale
+  module_path: tomopy.prep.alignment
+  parameters: {}
diff --git a/templates/tomopy/1.14/tomopy.prep.alignment/shift_images.yaml b/templates/tomopy/1.14/tomopy.prep.alignment/shift_images.yaml
index f03cac861..8ed8fc2f6 100644
--- a/templates/tomopy/1.14/tomopy.prep.alignment/shift_images.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.alignment/shift_images.yaml
@@ -1,6 +1,5 @@
-- tomopy.prep.alignment:
-    shift_images:
-      data_in: tomo
-      data_out: tomo
-      sx: REQUIRED
-      sy: REQUIRED
+- method: shift_images
+  module_path: tomopy.prep.alignment
+  parameters:
+    sx: REQUIRED
+    sy: REQUIRED
diff --git a/templates/tomopy/1.14/tomopy.prep.alignment/tilt.yaml b/templates/tomopy/1.14/tomopy.prep.alignment/tilt.yaml
new file mode 100644
index 000000000..42aa746b8
--- /dev/null
+++ b/templates/tomopy/1.14/tomopy.prep.alignment/tilt.yaml
@@ -0,0 +1,6 @@
+- method: tilt
+  module_path: tomopy.prep.alignment
+  parameters:
+    obj: REQUIRED
+    rad: 0
+    phi: 0
diff --git a/templates/tomopy/1.14/tomopy.prep.normalize/minus_log.yaml b/templates/tomopy/1.14/tomopy.prep.normalize/minus_log.yaml
index 1d2429c22..6d9558919 100644
--- a/templates/tomopy/1.14/tomopy.prep.normalize/minus_log.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.normalize/minus_log.yaml
@@ -1,4 +1,3 @@
-- tomopy.prep.normalize:
-    minus_log:
-      data_in: tomo
-      data_out: tomo
+- method: minus_log
+  module_path: tomopy.prep.normalize
+  parameters: {}
diff --git a/templates/tomopy/1.14/tomopy.prep.normalize/normalize.yaml b/templates/tomopy/1.14/tomopy.prep.normalize/normalize.yaml
index d2bcd5123..f917f4f94 100644
--- a/templates/tomopy/1.14/tomopy.prep.normalize/normalize.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.normalize/normalize.yaml
@@ -1,6 +1,5 @@
-- tomopy.prep.normalize:
-    normalize:
-      data_in: tomo
-      data_out: tomo
-      cutoff: null
-      averaging: mean
+- method: normalize
+  module_path: tomopy.prep.normalize
+  parameters:
+    cutoff: null
+    averaging: mean
diff --git a/templates/tomopy/1.14/tomopy.prep.normalize/normalize_bg.yaml b/templates/tomopy/1.14/tomopy.prep.normalize/normalize_bg.yaml
index e80daa709..28088716e 100644
--- a/templates/tomopy/1.14/tomopy.prep.normalize/normalize_bg.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.normalize/normalize_bg.yaml
@@ -1,5 +1,4 @@
-- tomopy.prep.normalize:
-    normalize_bg:
-      data_in: tomo
-      data_out: tomo
-      air: 1
+- method: normalize_bg
+  module_path: tomopy.prep.normalize
+  parameters:
+    air: 1
diff --git a/templates/tomopy/1.14/tomopy.prep.normalize/normalize_nf.yaml b/templates/tomopy/1.14/tomopy.prep.normalize/normalize_nf.yaml
index 9bda58233..987749f86 100644
--- a/templates/tomopy/1.14/tomopy.prep.normalize/normalize_nf.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.normalize/normalize_nf.yaml
@@ -1,7 +1,6 @@
-- tomopy.prep.normalize:
-    normalize_nf:
-      data_in: tomo
-      data_out: tomo
-      flat_loc: REQUIRED
-      cutoff: null
-      averaging: mean
+- method: normalize_nf
+  module_path: tomopy.prep.normalize
+  parameters:
+    flat_loc: REQUIRED
+    cutoff: null
+    averaging: mean
diff --git a/templates/tomopy/1.14/tomopy.prep.normalize/normalize_roi.yaml b/templates/tomopy/1.14/tomopy.prep.normalize/normalize_roi.yaml
index 22a48026c..eae28603c 100644
--- a/templates/tomopy/1.14/tomopy.prep.normalize/normalize_roi.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.normalize/normalize_roi.yaml
@@ -1,9 +1,8 @@
-- tomopy.prep.normalize:
-    normalize_roi:
-      data_in: tomo
-      data_out: tomo
-      roi:
-      - 0
-      - 0
-      - 10
-      - 10
+- method: normalize_roi
+  module_path: tomopy.prep.normalize
+  parameters:
+    roi:
+    - 0
+    - 0
+    - 10
+    - 10
diff --git a/templates/tomopy/1.14/tomopy.prep.phase/retrieve_phase.yaml b/templates/tomopy/1.14/tomopy.prep.phase/retrieve_phase.yaml
index 24268b43f..e020a75b7 100644
--- a/templates/tomopy/1.14/tomopy.prep.phase/retrieve_phase.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.phase/retrieve_phase.yaml
@@ -1,9 +1,8 @@
-- tomopy.prep.phase:
-    retrieve_phase:
-      data_in: tomo
-      data_out: tomo
-      pixel_size: 0.0001
-      dist: 50
-      energy: 20
-      alpha: 0.001
-      pad: true
+- method: retrieve_phase
+  module_path: tomopy.prep.phase
+  parameters:
+    pixel_size: 0.0001
+    dist: 50
+    energy: 20
+    alpha: 0.001
+    pad: true
diff --git a/templates/tomopy/1.14/tomopy.prep.stripe/remove_all_stripe.yaml b/templates/tomopy/1.14/tomopy.prep.stripe/remove_all_stripe.yaml
index d3b74f19b..19c77f31d 100644
--- a/templates/tomopy/1.14/tomopy.prep.stripe/remove_all_stripe.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.stripe/remove_all_stripe.yaml
@@ -1,8 +1,7 @@
-- tomopy.prep.stripe:
-    remove_all_stripe:
-      data_in: tomo
-      data_out: tomo
-      snr: 3
-      la_size: 61
-      sm_size: 21
-      dim: 1
+- method: remove_all_stripe
+  module_path: tomopy.prep.stripe
+  parameters:
+    snr: 3
+    la_size: 61
+    sm_size: 21
+    dim: 1
diff --git a/templates/tomopy/1.14/tomopy.prep.stripe/remove_dead_stripe.yaml b/templates/tomopy/1.14/tomopy.prep.stripe/remove_dead_stripe.yaml
index 33094ad5e..18dce1f5d 100644
--- a/templates/tomopy/1.14/tomopy.prep.stripe/remove_dead_stripe.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.stripe/remove_dead_stripe.yaml
@@ -1,7 +1,6 @@
-- tomopy.prep.stripe:
-    remove_dead_stripe:
-      data_in: tomo
-      data_out: tomo
-      snr: 3
-      size: 51
-      norm: true
+- method: remove_dead_stripe
+  module_path: tomopy.prep.stripe
+  parameters:
+    snr: 3
+    size: 51
+    norm: true
diff --git a/templates/tomopy/1.14/tomopy.prep.stripe/remove_large_stripe.yaml b/templates/tomopy/1.14/tomopy.prep.stripe/remove_large_stripe.yaml
index 527b15324..ca24d3a0a 100644
--- a/templates/tomopy/1.14/tomopy.prep.stripe/remove_large_stripe.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.stripe/remove_large_stripe.yaml
@@ -1,8 +1,7 @@
-- tomopy.prep.stripe:
-    remove_large_stripe:
-      data_in: tomo
-      data_out: tomo
-      snr: 3
-      size: 51
-      drop_ratio: 0.1
-      norm: true
+- method: remove_large_stripe
+  module_path: tomopy.prep.stripe
+  parameters:
+    snr: 3
+    size: 51
+    drop_ratio: 0.1
+    norm: true
diff --git a/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_based_filtering.yaml b/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_based_filtering.yaml
index 2de8496b5..ee584fb6f 100644
--- a/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_based_filtering.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_based_filtering.yaml
@@ -1,7 +1,6 @@
-- tomopy.prep.stripe:
-    remove_stripe_based_filtering:
-      data_in: tomo
-      data_out: tomo
-      sigma: 3
-      size: null
-      dim: 1
+- method: remove_stripe_based_filtering
+  module_path: tomopy.prep.stripe
+  parameters:
+    sigma: 3
+    size: null
+    dim: 1
diff --git a/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_based_fitting.yaml b/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_based_fitting.yaml
index 8b6db982c..9dc35b7a3 100644
--- a/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_based_fitting.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_based_fitting.yaml
@@ -1,8 +1,7 @@
-- tomopy.prep.stripe:
-    remove_stripe_based_fitting:
-      data_in: tomo
-      data_out: tomo
-      order: 3
-      sigma: !!python/tuple
-      - 5
-      - 20
+- method: remove_stripe_based_fitting
+  module_path: tomopy.prep.stripe
+  parameters:
+    order: 3
+    sigma: !!python/tuple
+    - 5
+    - 20
diff --git a/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_based_interpolation.yaml b/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_based_interpolation.yaml
index d8b004d5f..29f7a6c59 100644
--- a/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_based_interpolation.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_based_interpolation.yaml
@@ -1,8 +1,7 @@
-- tomopy.prep.stripe:
-    remove_stripe_based_interpolation:
-      data_in: tomo
-      data_out: tomo
-      snr: 3
-      size: 31
-      drop_ratio: 0.1
-      norm: true
+- method: remove_stripe_based_interpolation
+  module_path: tomopy.prep.stripe
+  parameters:
+    snr: 3
+    size: 31
+    drop_ratio: 0.1
+    norm: true
diff --git a/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_based_sorting.yaml b/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_based_sorting.yaml
index 3d0ec0377..f5f8d799a 100644
--- a/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_based_sorting.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_based_sorting.yaml
@@ -1,6 +1,5 @@
-- tomopy.prep.stripe:
-    remove_stripe_based_sorting:
-      data_in: tomo
-      data_out: tomo
-      size: null
-      dim: 1
+- method: remove_stripe_based_sorting
+  module_path: tomopy.prep.stripe
+  parameters:
+    size: null
+    dim: 1
diff --git a/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_fw.yaml b/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_fw.yaml
index 0d800889c..5aa654390 100644
--- a/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_fw.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_fw.yaml
@@ -1,8 +1,7 @@
-- tomopy.prep.stripe:
-    remove_stripe_fw:
-      data_in: tomo
-      data_out: tomo
-      level: null
-      wname: db5
-      sigma: 2
-      pad: true
+- method: remove_stripe_fw
+  module_path: tomopy.prep.stripe
+  parameters:
+    level: null
+    wname: db5
+    sigma: 2
+    pad: true
diff --git a/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_sf.yaml b/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_sf.yaml
index 4fd3c71b2..c63f6f006 100644
--- a/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_sf.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_sf.yaml
@@ -1,5 +1,4 @@
-- tomopy.prep.stripe:
-    remove_stripe_sf:
-      data_in: tomo
-      data_out: tomo
-      size: 5
+- method: remove_stripe_sf
+  module_path: tomopy.prep.stripe
+  parameters:
+    size: 5
diff --git a/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_ti.yaml b/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_ti.yaml
index 42756feab..3a0152b56 100644
--- a/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_ti.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.stripe/remove_stripe_ti.yaml
@@ -1,6 +1,5 @@
-- tomopy.prep.stripe:
-    remove_stripe_ti:
-      data_in: tomo
-      data_out: tomo
-      nblock: 0
-      alpha: 1.5
+- method: remove_stripe_ti
+  module_path: tomopy.prep.stripe
+  parameters:
+    nblock: 0
+    alpha: 1.5
diff --git a/templates/tomopy/1.14/tomopy.prep.stripe/stripes_detect3d.yaml b/templates/tomopy/1.14/tomopy.prep.stripe/stripes_detect3d.yaml
index 8024e378a..e00632891 100644
--- a/templates/tomopy/1.14/tomopy.prep.stripe/stripes_detect3d.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.stripe/stripes_detect3d.yaml
@@ -1,6 +1,5 @@
-- tomopy.prep.stripe:
-    stripes_detect3d:
-      data_in: tomo
-      data_out: tomo
-      size: 10
-      radius: 3
+- method: stripes_detect3d
+  module_path: tomopy.prep.stripe
+  parameters:
+    size: 10
+    radius: 3
diff --git a/templates/tomopy/1.14/tomopy.prep.stripe/stripes_mask3d.yaml b/templates/tomopy/1.14/tomopy.prep.stripe/stripes_mask3d.yaml
index 34b5bebf4..198549b37 100644
--- a/templates/tomopy/1.14/tomopy.prep.stripe/stripes_mask3d.yaml
+++ b/templates/tomopy/1.14/tomopy.prep.stripe/stripes_mask3d.yaml
@@ -1,10 +1,9 @@
-- tomopy.prep.stripe:
-    stripes_mask3d:
-      data_in: tomo
-      data_out: tomo
-      weights: REQUIRED
-      threshold: 0.6
-      min_stripe_length: 20
-      min_stripe_depth: 10
-      min_stripe_width: 5
-      sensitivity_perc: 85.0
+- method: stripes_mask3d
+  module_path: tomopy.prep.stripe
+  parameters:
+    weights: REQUIRED
+    threshold: 0.6
+    min_stripe_length: 20
+    min_stripe_depth: 10
+    min_stripe_width: 5
+    sensitivity_perc: 85.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
new file mode 100644
index 000000000..c2efca89c
--- /dev/null
+++ b/templates/tomopy/1.14/tomopy.recon.algorithm/init_tomo.yaml
@@ -0,0 +1,5 @@
+- 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 1c97d485b..b1b2972e9 100644
--- a/templates/tomopy/1.14/tomopy.recon.algorithm/recon.yaml
+++ b/templates/tomopy/1.14/tomopy.recon.algorithm/recon.yaml
@@ -1,9 +1,8 @@
-- tomopy.recon.algorithm:
-    recon:
-      data_in: tomo
-      data_out: tomo
-      center: null
-      sinogram_order: false
-      algorithm: null
-      init_recon: null
-      '#additional parameters': AVAILABLE
+- method: recon
+  module_path: tomopy.recon.algorithm
+  parameters:
+    center: ${{centering.side_outputs.centre_of_rotation}}
+    sinogram_order: false
+    algorithm: null
+    init_recon: null
+    #additional parameters: AVAILABLE
diff --git a/templates/tomopy/1.14/tomopy.recon.rotation/find_center.yaml b/templates/tomopy/1.14/tomopy.recon.rotation/find_center.yaml
index b4b94714f..fb2b45dc7 100644
--- a/templates/tomopy/1.14/tomopy.recon.rotation/find_center.yaml
+++ b/templates/tomopy/1.14/tomopy.recon.rotation/find_center.yaml
@@ -1,14 +1,15 @@
-- tomopy.recon.rotation:
-    find_center:
-      data_in: tomo
-      data_out:
-      - cor
-      - overlap
-      - side
-      - overlap_position
-      ind: null
-      init: null
-      tol: 0.5
-      mask: true
-      ratio: 1.0
-      sinogram_order: false
+- method: find_center
+  module_path: tomopy.recon.rotation
+  parameters:
+    ind: null
+    init: null
+    tol: 0.5
+    mask: true
+    ratio: 1.0
+    sinogram_order: false
+  id: centering
+  side_outputs:
+    cor: centre_of_rotation
+    overlap: overlap
+    side: side
+    overlap_position: overlap_position
diff --git a/templates/tomopy/1.14/tomopy.recon.rotation/find_center_pc.yaml b/templates/tomopy/1.14/tomopy.recon.rotation/find_center_pc.yaml
index d13373a73..30961eea3 100644
--- a/templates/tomopy/1.14/tomopy.recon.rotation/find_center_pc.yaml
+++ b/templates/tomopy/1.14/tomopy.recon.rotation/find_center_pc.yaml
@@ -1,8 +1,7 @@
-- tomopy.recon.rotation:
-    find_center_pc:
-      data_in: tomo
-      data_out: tomo
-      proj1: REQUIRED
-      proj2: REQUIRED
-      tol: 0.5
-      rotc_guess: null
+- method: find_center_pc
+  module_path: tomopy.recon.rotation
+  parameters:
+    proj1: REQUIRED
+    proj2: REQUIRED
+    tol: 0.5
+    rotc_guess: null
diff --git a/templates/tomopy/1.14/tomopy.recon.rotation/find_center_vo.yaml b/templates/tomopy/1.14/tomopy.recon.rotation/find_center_vo.yaml
index 78ada91d5..1e213797c 100644
--- a/templates/tomopy/1.14/tomopy.recon.rotation/find_center_vo.yaml
+++ b/templates/tomopy/1.14/tomopy.recon.rotation/find_center_vo.yaml
@@ -1,11 +1,13 @@
-- tomopy.recon.rotation:
-    find_center_vo:
-      data_in: tomo
-      data_out: cor
-      ind: null
-      smin: -50
-      smax: 50
-      srad: 6
-      step: 0.25
-      ratio: 0.5
-      drop: 20
+- method: find_center_vo
+  module_path: tomopy.recon.rotation
+  parameters:
+    ind: null
+    smin: -50
+    smax: 50
+    srad: 6
+    step: 0.25
+    ratio: 0.5
+    drop: 20
+  id: centering
+  side_outputs:
+    cor: centre_of_rotation
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
new file mode 100644
index 000000000..4d623bf61
--- /dev/null
+++ b/templates/tomopy/1.14/tomopy.recon.rotation/mask_empty_slice.yaml
@@ -0,0 +1,4 @@
+- method: mask_empty_slice
+  module_path: tomopy.recon.rotation
+  parameters:
+    threshold: 0.25
diff --git a/templates/tomopy/1.14/tomopy.recon.rotation/write_center.yaml b/templates/tomopy/1.14/tomopy.recon.rotation/write_center.yaml
index 33e5d6448..dc7b2bb5b 100644
--- a/templates/tomopy/1.14/tomopy.recon.rotation/write_center.yaml
+++ b/templates/tomopy/1.14/tomopy.recon.rotation/write_center.yaml
@@ -1,12 +1,11 @@
-- tomopy.recon.rotation:
-    write_center:
-      data_in: tomo
-      data_out: tomo
-      dpath: tmp/center
-      cen_range: null
-      ind: null
-      mask: false
-      ratio: 1.0
-      sinogram_order: false
-      algorithm: gridrec
-      filter_name: parzen
+- method: write_center
+  module_path: tomopy.recon.rotation
+  parameters:
+    dpath: tmp/center
+    cen_range: null
+    ind: null
+    mask: false
+    ratio: 1.0
+    sinogram_order: false
+    algorithm: gridrec
+    filter_name: parzen
diff --git a/templates/tomopy/1.14/tomopy.sim.project/add_drift.yaml b/templates/tomopy/1.14/tomopy.sim.project/add_drift.yaml
index acbd29af5..5535fdd2d 100644
--- a/templates/tomopy/1.14/tomopy.sim.project/add_drift.yaml
+++ b/templates/tomopy/1.14/tomopy.sim.project/add_drift.yaml
@@ -1,7 +1,6 @@
-- tomopy.sim.project:
-    add_drift:
-      data_in: tomo
-      data_out: tomo
-      amp: 0.2
-      period: 50
-      mean: 1
+- method: add_drift
+  module_path: tomopy.sim.project
+  parameters:
+    amp: 0.2
+    period: 50
+    mean: 1
diff --git a/templates/tomopy/1.14/tomopy.sim.project/add_focal_spot_blur.yaml b/templates/tomopy/1.14/tomopy.sim.project/add_focal_spot_blur.yaml
index 123a543c4..64c99c66e 100644
--- a/templates/tomopy/1.14/tomopy.sim.project/add_focal_spot_blur.yaml
+++ b/templates/tomopy/1.14/tomopy.sim.project/add_focal_spot_blur.yaml
@@ -1,5 +1,4 @@
-- tomopy.sim.project:
-    add_focal_spot_blur:
-      data_in: tomo
-      data_out: tomo
-      spotsize: REQUIRED
+- method: add_focal_spot_blur
+  module_path: tomopy.sim.project
+  parameters:
+    spotsize: REQUIRED
diff --git a/templates/tomopy/1.14/tomopy.sim.project/add_gaussian.yaml b/templates/tomopy/1.14/tomopy.sim.project/add_gaussian.yaml
index 3869dcfce..e9b7031b8 100644
--- a/templates/tomopy/1.14/tomopy.sim.project/add_gaussian.yaml
+++ b/templates/tomopy/1.14/tomopy.sim.project/add_gaussian.yaml
@@ -1,6 +1,5 @@
-- tomopy.sim.project:
-    add_gaussian:
-      data_in: tomo
-      data_out: tomo
-      mean: 0
-      std: null
+- method: add_gaussian
+  module_path: tomopy.sim.project
+  parameters:
+    mean: 0
+    std: null
diff --git a/templates/tomopy/1.14/tomopy.sim.project/add_poisson.yaml b/templates/tomopy/1.14/tomopy.sim.project/add_poisson.yaml
index 141ab753b..4c68479d0 100644
--- a/templates/tomopy/1.14/tomopy.sim.project/add_poisson.yaml
+++ b/templates/tomopy/1.14/tomopy.sim.project/add_poisson.yaml
@@ -1,4 +1,3 @@
-- tomopy.sim.project:
-    add_poisson:
-      data_in: tomo
-      data_out: tomo
+- method: add_poisson
+  module_path: tomopy.sim.project
+  parameters: {}
diff --git a/templates/tomopy/1.14/tomopy.sim.project/add_rings.yaml b/templates/tomopy/1.14/tomopy.sim.project/add_rings.yaml
index 4fdb843ab..a96c6cb8f 100644
--- a/templates/tomopy/1.14/tomopy.sim.project/add_rings.yaml
+++ b/templates/tomopy/1.14/tomopy.sim.project/add_rings.yaml
@@ -1,5 +1,4 @@
-- tomopy.sim.project:
-    add_rings:
-      data_in: tomo
-      data_out: tomo
-      std: 0.05
+- method: add_rings
+  module_path: tomopy.sim.project
+  parameters:
+    std: 0.05
diff --git a/templates/tomopy/1.14/tomopy.sim.project/add_salt_pepper.yaml b/templates/tomopy/1.14/tomopy.sim.project/add_salt_pepper.yaml
index eaa1d4e5e..9777ac341 100644
--- a/templates/tomopy/1.14/tomopy.sim.project/add_salt_pepper.yaml
+++ b/templates/tomopy/1.14/tomopy.sim.project/add_salt_pepper.yaml
@@ -1,6 +1,5 @@
-- tomopy.sim.project:
-    add_salt_pepper:
-      data_in: tomo
-      data_out: tomo
-      prob: 0.01
-      val: null
+- method: add_salt_pepper
+  module_path: tomopy.sim.project
+  parameters:
+    prob: 0.01
+    val: null
diff --git a/templates/tomopy/1.14/tomopy.sim.project/add_zingers.yaml b/templates/tomopy/1.14/tomopy.sim.project/add_zingers.yaml
index 8c228a4c9..5048a4c7e 100644
--- a/templates/tomopy/1.14/tomopy.sim.project/add_zingers.yaml
+++ b/templates/tomopy/1.14/tomopy.sim.project/add_zingers.yaml
@@ -1,6 +1,5 @@
-- tomopy.sim.project:
-    add_zingers:
-      data_in: tomo
-      data_out: tomo
-      f: 0.01
-      sat: 65536
+- method: add_zingers
+  module_path: tomopy.sim.project
+  parameters:
+    f: 0.01
+    sat: 65536
diff --git a/templates/tomopy/1.14/tomopy.sim.project/angles.yaml b/templates/tomopy/1.14/tomopy.sim.project/angles.yaml
new file mode 100644
index 000000000..dd5e94636
--- /dev/null
+++ b/templates/tomopy/1.14/tomopy.sim.project/angles.yaml
@@ -0,0 +1,6 @@
+- 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
new file mode 100644
index 000000000..dd7eb3fe3
--- /dev/null
+++ b/templates/tomopy/1.14/tomopy.sim.project/fan_to_para.yaml
@@ -0,0 +1,5 @@
+- 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
new file mode 100644
index 000000000..3fec2dc88
--- /dev/null
+++ b/templates/tomopy/1.14/tomopy.sim.project/para_to_fan.yaml
@@ -0,0 +1,5 @@
+- method: para_to_fan
+  module_path: tomopy.sim.project
+  parameters:
+    dist: REQUIRED
+    geom: REQUIRED
diff --git a/templates/tomopy/1.14/tomopy.sim.project/project.yaml b/templates/tomopy/1.14/tomopy.sim.project/project.yaml
index 7efc08e44..ed843f1a5 100644
--- a/templates/tomopy/1.14/tomopy.sim.project/project.yaml
+++ b/templates/tomopy/1.14/tomopy.sim.project/project.yaml
@@ -1,9 +1,8 @@
-- tomopy.sim.project:
-    project:
-      data_in: tomo
-      data_out: tomo
-      obj: REQUIRED
-      center: null
-      emission: true
-      pad: true
-      sinogram_order: false
+- method: project
+  module_path: tomopy.sim.project
+  parameters:
+    obj: REQUIRED
+    center: ${{centering.side_outputs.centre_of_rotation}}
+    emission: true
+    pad: true
+    sinogram_order: false
diff --git a/templates/tomopy/1.14/tomopy.sim.project/project2.yaml b/templates/tomopy/1.14/tomopy.sim.project/project2.yaml
index 773a827a1..562321403 100644
--- a/templates/tomopy/1.14/tomopy.sim.project/project2.yaml
+++ b/templates/tomopy/1.14/tomopy.sim.project/project2.yaml
@@ -1,11 +1,10 @@
-- tomopy.sim.project:
-    project2:
-      data_in: tomo
-      data_out: tomo
-      objx: REQUIRED
-      objy: REQUIRED
-      center: null
-      emission: true
-      pad: true
-      sinogram_order: false
-      axis: 0
+- method: project2
+  module_path: tomopy.sim.project
+  parameters:
+    objx: REQUIRED
+    objy: REQUIRED
+    center: ${{centering.side_outputs.centre_of_rotation}}
+    emission: true
+    pad: true
+    sinogram_order: false
+    axis: 0
diff --git a/templates/tomopy/1.14/tomopy.sim.project/project3.yaml b/templates/tomopy/1.14/tomopy.sim.project/project3.yaml
index fec2ae8d7..6bc3cde1e 100644
--- a/templates/tomopy/1.14/tomopy.sim.project/project3.yaml
+++ b/templates/tomopy/1.14/tomopy.sim.project/project3.yaml
@@ -1,12 +1,11 @@
-- tomopy.sim.project:
-    project3:
-      data_in: tomo
-      data_out: tomo
-      objx: REQUIRED
-      objy: REQUIRED
-      objz: REQUIRED
-      center: null
-      emission: true
-      pad: true
-      sinogram_order: false
-      axis: 0
+- method: project3
+  module_path: tomopy.sim.project
+  parameters:
+    objx: REQUIRED
+    objy: REQUIRED
+    objz: REQUIRED
+    center: ${{centering.side_outputs.centre_of_rotation}}
+    emission: true
+    pad: true
+    sinogram_order: false
+    axis: 0
diff --git a/templates/yaml_templates_generator.py b/templates/yaml_templates_generator.py
index 8372a9570..315306dfb 100644
--- a/templates/yaml_templates_generator.py
+++ b/templates/yaml_templates_generator.py
@@ -15,20 +15,21 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 # ---------------------------------------------------------------------------
-# Created By  : Daniil Kazantsev <scientificsoftware@diamond.ac.uk>
+# Created By  : Tomography Team <scientificsoftware@diamond.ac.uk>
 # Created Date: 14/October/2022
-# version ='0.2'
+# version ='0.3'
 # ---------------------------------------------------------------------------
 """Script that exposes all functions of a given software package as YAML templates.
 
 Please run the generator as:
-python -m yaml_templates_generator -m /path/to/modules.yml -o /path/to/output/
+    python -m yaml_templates_generator -m /path/to/modules.yml -o /path/to/output/
 """
 import argparse
 import importlib
 import inspect
 import os
 import re
+from typing import List, Dict
 
 import yaml
 
@@ -37,37 +38,14 @@ def yaml_generator(path_to_modules: str, output_folder: str) -> int:
     """function that exposes all method of a given software package as YAML templates
 
     Args:
-        path_to_modules (str): path to the list of modules yaml file
-        output_folder (str): path to output folder with saved templates
+        path_to_modules: path to the list of modules yaml file
+        output_folder: path to output folder with saved templates
 
     Returns:
-        int: returns zero if the processing is succesfull
+        returns zero if the processing is succesfull
     """
-    # Can work with any software in principle,
-    # but for TomoPy and httomolib there are additional keys
-    # that needed to be discarded in templates in order to let
-    # httomo work smoothly.
-    discard_keys = [
-        "tomo",
-        "arr",
-        "prj",
-        "data",
-        "ncore",
-        "nchunk",
-        "flats",
-        "flat",
-        "dark",
-        "darks",
-        "theta",
-        "out",
-        "ang",        
-        "comm_rank",
-        "out_dir",
-        "angles",
-        "gpu_id",
-    ]  # discard from parameters list
-
-    no_data_out_modules = ["save_to_images"]  # discard data_out from certain modules
+    discard_keys = _get_discard_keys()
+    no_data_out_modules = _get_discard_data_out()
 
     # open YAML file with modules to inspect
     with open(path_to_modules, "r") as stream:
@@ -97,16 +75,8 @@ def yaml_generator(path_to_modules: str, output_folder: str) -> int:
             get_method_docs = inspect.getdoc(getattr(imported_module, methods_list[m]))
 
             # put the parameters in the dictionary
-            params_list: list = []
-            params_dict: dict = {}
-            params_dict["data_in"] = "tomo"  # default dataset names
-            # dealing with special cases for "data_out"
-            if method_name not in no_data_out_modules:
-                params_dict["data_out"] = "tomo"
-            if method_name in "find_center_vo":
-                params_dict["data_out"] = "cor"
-            if method_name in "find_center_360":
-                params_dict["data_out"] = ["cor", "overlap", "side", "overlap_position"]
+            params_list: List = []
+            params_dict: Dict = {}
             for k, v in get_method_params.parameters.items():
                 if v is not None:
                     append = True
@@ -115,25 +85,119 @@ def yaml_generator(path_to_modules: str, output_folder: str) -> int:
                             append = False
                             break
                     if append:
-                        if str(v).find("=") == -1 and str(k) != "kwargs":
-                            params_dict[str(k)] = "REQUIRED"
-                        elif str(k) == "kwargs":
-                            params_dict["#additional parameters"] = "AVAILABLE"
-                        else:
-                            params_dict[str(k)] = v.default
+                        _set_param_value(k, v, params_dict)
+            method_dict = {
+                "method": method_name,
+                "module_path": module_name,
+                "parameters": params_dict,
+            }
+            _set_dict_special_cases(method_dict, method_name)
+            params_list = [method_dict]
+            _save_yaml(module_name, method_name, params_list)
+    return 0
 
-            params_list = [{module_name: {method_name: params_dict}}]
 
-            # save the list as a YAML file
-            path_dir = output_folder + "/" + module_name
-            path_file = path_dir + "/" + str(method_name) + ".yaml"
+def _set_param_value(k: int, v: int, params_dict: Dict):
+    """Set param value for method inside dictionary
+    Args:
+        k: Method name, dict key
+        v: Method value, dict value
+        params_dict: Parameter dictionary
+    """
+    if str(v).find("=") == -1 and str(k) != "kwargs":
+        params_dict[str(k)] = "REQUIRED"
+    elif str(k) == "kwargs":
+        params_dict["#additional parameters"] = "AVAILABLE"
+    elif str(k) == "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}}"
+    else:
+        params_dict[str(k)] = v.default
+
+
+def _save_yaml(module_name: str, method_name: str, params_list: List[str]):
+    """Save the list as a YAML file
+    Args:
+        module_name: Name of module
+        method_name: Name of method
+        params_list: List of parameters
+    """
+    path_dir = output_folder + "/" + module_name
+    path_file = path_dir + "/" + str(method_name) + ".yaml"
 
-            if not os.path.exists(path_dir):
-                os.makedirs(path_dir)
+    if not os.path.exists(path_dir):
+        os.makedirs(path_dir)
 
-            with open(path_file, "w") as file:
-                outputs = yaml.dump(params_list, file, sort_keys=False)
-    return 0
+    with open(path_file, "w") as file:
+        outputs = yaml.dump(params_list, file, sort_keys=False)
+
+
+def _set_dict_special_cases(method_dict: Dict, method_name: str):
+    """Dealing with special cases for "data_out"
+
+    Args:
+        method_dict: Dictionary of modules and parameters
+        method_name: Name of method
+    """
+    if method_name in "find_center_vo":
+        method_dict["id"] = "centering"
+        method_dict["side_outputs"] = {"cor": "centre_of_rotation"}
+    if method_name in "find_center_360":
+        method_dict["id"] = "centering"
+        method_dict["side_outputs"] = {
+            "cor": "centre_of_rotation",
+            "overlap": "overlap",
+            "side": "side",
+            "overlap_position": "overlap_position",
+        }
+    if method_name in "calculate_stats":
+        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
+
+    Returns: list of data_out to discard
+    """
+    discard_data_out = ["save_to_images"]
+    return discard_data_out
+
+
+def _get_discard_keys() -> List[str]:
+    """Can work with any software in principle,
+    but for TomoPy and httomolib there are additional keys
+    that needed to be discarded in templates in order to let
+    httomo work smoothly.
+
+    Returns: List of keys to discard
+    """
+    discard_keys = [
+        "in_file",
+        "data_in",
+        "tomo",
+        "arr",
+        "prj",
+        "data",
+        "ncore",
+        "nchunk",
+        "flats",
+        "flat",
+        "dark",
+        "darks",
+        "theta",
+        "out",
+        "ang",
+        "comm_rank",
+        "out_dir",
+        "angles",
+        "gpu_id",
+        "comm"
+    ]
+    return discard_keys
 
 
 def get_args():


Test patch:
diff --git a/samples/pipeline_template_examples/testing/empty_loader_stage.yaml b/samples/pipeline_template_examples/testing/empty_loader_stage.yaml
index 8acadcc42..aadfb12ad 100644
--- a/samples/pipeline_template_examples/testing/empty_loader_stage.yaml
+++ b/samples/pipeline_template_examples/testing/empty_loader_stage.yaml
@@ -1,54 +1,53 @@
 --- # loading
 --- # pre-processing
-- tomopy.prep.normalize:
-    normalize:
-      data_in: tomo
-      data_out: tomo
-      cutoff: null
-- tomopy.prep.normalize:
-    minus_log:
-      data_in: tomo
-      data_out: tomo
+- method: normalize
+  module_path: tomopy.prep.normalize
+  parameters:
+    cutoff: null
+    averaging: mean
+- method: minus_log
+  module_path: tomopy.prep.normalize
+  parameters: {}
 --- # main processing
-- tomopy.prep.stripe:
-    remove_stripe_fw:
-      data_in: tomo
-      data_out: tomo
-      level: null
-      wname: db5
-      sigma: 2
-      pad: true
-- tomopy.recon.rotation:
-    find_center_vo:
-      data_in: tomo
-      data_out: cor
-      ind: mid
-      smin: -50
-      smax: 50
-      srad: 6
-      step: 0.25
-      ratio: 0.5
-      drop: 20
-- tomopy.recon.algorithm:
-    recon:
-      data_in: tomo
-      data_out: tomo
-      center: cor
-      sinogram_order: false
-      algorithm: gridrec
-      init_recon: null
-      save_result: true
-      #additional parameters': AVAILABLE
-- httomolib.misc.images:
-    save_to_images:
-      data_in: tomo
-      subfolder_name: images
-      axis: 1
-      file_format: tif
-      bits: 8
-      perc_range_min: 0.0
-      perc_range_max: 100.0
-      jpeg_quality: 95
-      glob_stats: true
+- method: remove_stripe_fw
+  module_path: tomopy.prep.stripe
+  parameters:
+    level: null
+    wname: db5
+    sigma: 2
+    pad: true
+- method: find_center_vo
+  module_path: tomopy.recon.rotation
+  parameters:
+    ind: mid
+    smin: -50
+    smax: 50
+    srad: 6
+    step: 0.25
+    ratio: 0.5
+    drop: 20
+  id: centering
+  side_outputs:
+    cor: centre_of_rotation
+- method: recon
+  module_path: tomopy.recon.algorithm
+  parameters:
+    center: ${{centering.side_outputs.centre_of_rotation}}
+    sinogram_order: false
+    algorithm: gridrec
+    init_recon: null
+    save_result: true
+    #additional parameters: AVAILABLE
+- method: save_to_images
+  module_path: httomolib.misc.images
+  parameters:
+    subfolder_name: images
+    axis: 1
+    file_format: tif
+    bits: 8
+    perc_range_min: 0.0
+    perc_range_max: 100.0
+    jpeg_quality: 95
+    glob_stats: true
 
 
diff --git a/samples/pipeline_template_examples/testing/incorrect_method.yaml b/samples/pipeline_template_examples/testing/incorrect_method.yaml
index 79de7e3fc..b838fbf67 100644
--- a/samples/pipeline_template_examples/testing/incorrect_method.yaml
+++ b/samples/pipeline_template_examples/testing/incorrect_method.yaml
@@ -1,25 +1,29 @@
 --- # loading
-- httomo.data.hdf.loaders:
-    standard_tomo:
-      name: tomo
-      data_path: entry1/tomo_entry/data/data
-      image_key_path: entry1/tomo_entry/instrument/detector/image_key
-      preview:
-        - 
-        - start: 30
-          stop: 60
-        - 
-      pad: 0
+- method: standard_tomo
+  module_path: httomo.data.hdf.loaders
+  parameters:
+    name: tomo
+    in_file: tests/test_data/tomo_standard.nxs
+    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: 30
+        stop: 60
+      -
+    pad: 0
 --- # pre-processing
-- tomopy.misc.corr:
-    median_filters:
-      data: tomo
-      data_out: tomo
-      size: tomo
-      axis: 0
+- method: median_filters # incorrect method name
+  module_path: tomopy.misc.corr
+  parameters:
+    size: tomo # incorrect size parameter
+    axis: 0
 --- # main processing
-- tomopy.prep.normalize:
-    normalize:
-      data_in: tomo
-      data_out: tomo
-      cutoff: null
+- method: normalize
+  module_path: tomopy.prep.normalize
+  parameters:
+    cutoff: null
+    averaging: mean
diff --git a/samples/pipeline_template_examples/testing/incorrect_path.yaml b/samples/pipeline_template_examples/testing/incorrect_path.yaml
index 685cb8a6f..7323a1d1a 100644
--- a/samples/pipeline_template_examples/testing/incorrect_path.yaml
+++ b/samples/pipeline_template_examples/testing/incorrect_path.yaml
@@ -1,20 +1,26 @@
 --- # loading
-- httomo.data.hdf.loaders:
-    standard_tomo:
-      name: tomo
-      data_path: entry1/tomo_entry/data/data
-      image_key_path: entry1/tomo_entry/instrument/detect/image_key
+- method: standard_tomo
+  module_path: httomo.data.hdf.loaders
+  parameters:
+    name: tomo
+    in_file: tests/test_data/tomo_standard.nxs
+    data_path: entry1/tomo_entry/data/data
+    image_key_path: entry1/tomo_entry/instrument/detect/image_key #incorrect path
+    rotation_angles:
+      data_path: /entry1/tomo_entry/data/rotation_angle
+    dimension: 1
+    preview: [None, None, None]
+    pad: 0
 --- # pre-processing
-- tomopy.prep.normalize:
-    minus_log:
-      data_in: tomo
-      data_out: tomo
+- method: minus_log
+  module_path: tomopy.prep.normalize
+  parameters: {}
 --- # main processing
-- tomopy.prep.stripe:
-    remove_stripe_fw:
-      data_in: tomo
-      data_out: tomo
-      level: null
-      wname: db5
-      sigma: 2
-      pad: true
+- method: remove_stripe_fw
+  module_path: tomopy.prep.stripe
+  parameters:
+    level: null
+    wname: db5
+    sigma: 2
+    pad: true
+
diff --git a/samples/pipeline_template_examples/testing/invalid_loader_stage.yaml b/samples/pipeline_template_examples/testing/invalid_loader_stage.yaml
index 2dccbe04e..07c4cb7ba 100644
--- a/samples/pipeline_template_examples/testing/invalid_loader_stage.yaml
+++ b/samples/pipeline_template_examples/testing/invalid_loader_stage.yaml
@@ -1,60 +1,63 @@
 --- # loading
-- httomo.data.hdf.loaders:
-    standard_tomo:
-      name: tomo
-      data_path: /entry1/tomo_entry/data/data
-      image_key_path: /entry1/tomo_entry/instrument/detector/image_key
-      dimension: 1
-      pad: 0
-- tomopy.prep.normalize:
-    normalize:
-      data_in: tomo
-      data_out: tomo
-      cutoff: null
+- method: standard_tomo
+  module_path: httomo.data.hdf.loaders
+  parameters:
+    name: tomo
+    in_file: tests/test_data/tomo_standard.nxs
+    data_path: /entry1/tomo_entry/data/data
+    image_key_path: /entry1/tomo_entry/instrument/detector/image_key
+    rotation_angles:
+      data_path: /entry1/tomo_entry/data/rotation_angle
+    dimension: 1
+    pad: 0
+- method: normalize
+  module_path: tomopy.prep.normalize
+  parameters:
+    cutoff: null
+    averaging: mean
 --- # pre-processing
-- tomopy.prep.normalize:
-    minus_log:
-      data_in: tomo
-      data_out: tomo
+- method: minus_log
+  module_path: tomopy.prep.normalize
+  parameters: {}
 --- # main processing
-- tomopy.prep.stripe:
-    remove_stripe_fw:
-      data_in: tomo
-      data_out: tomo
-      level: null
-      wname: db5
-      sigma: 2
-      pad: true
-- tomopy.recon.rotation:
-    find_center_vo:
-      data_in: tomo
-      data_out: cor
-      ind: mid
-      smin: -50
-      smax: 50
-      srad: 6
-      step: 0.25
-      ratio: 0.5
-      drop: 20
-- tomopy.recon.algorithm:
-    recon:
-      data_in: tomo
-      data_out: tomo
-      center: cor
-      sinogram_order: false
-      algorithm: gridrec
-      init_recon: null
-      save_result: true
-      #additional parameters': AVAILABLE
-- httomolib.misc.images:
-    save_to_images:
-      data_in: tomo
-      subfolder_name: images
-      axis: 1
-      file_format: tif
-      bits: 8
-      perc_range_min: 0.0
-      perc_range_max: 100.0
-      jpeg_quality: 95
-      glob_stats: true
+- method: remove_stripe_fw
+  module_path: tomopy.prep.stripe
+  parameters:
+    level: null
+    wname: db5
+    sigma: 2
+    pad: true
+- method: find_center_vo
+  module_path: tomopy.recon.rotation
+  parameters:
+    ind: mid
+    smin: -50
+    smax: 50
+    srad: 6
+    step: 0.25
+    ratio: 0.5
+    drop: 20
+  id: centering
+  side_outputs:
+    cor: centre_of_rotation
+- method: recon
+  module_path: tomopy.recon.algorithm
+  parameters:
+    center: ${{centering.side_outputs.centre_of_rotation}}
+    sinogram_order: false
+    algorithm: gridrec
+    init_recon: null
+    save_result: true
+    #additional parameters': AVAILABLE
+- method: save_to_images
+  module_path: httomolib.misc.images
+  parameters:
+    subfolder_name: images
+    axis: 0
+    file_format: tif
+    bits: 8
+    perc_range_min: 0.0
+    perc_range_max: 100.0
+    jpeg_quality: 95
+    glob_stats: true
 
diff --git a/samples/pipeline_template_examples/testing/missing_loader_stage.yaml b/samples/pipeline_template_examples/testing/missing_loader_stage.yaml
index f523fd346..1a01eadb3 100644
--- a/samples/pipeline_template_examples/testing/missing_loader_stage.yaml
+++ b/samples/pipeline_template_examples/testing/missing_loader_stage.yaml
@@ -1,52 +1,51 @@
 --- # pre-processing
-- tomopy.prep.normalize:
-    normalize:
-      data_in: tomo
-      data_out: tomo
-      cutoff: null
-- tomopy.prep.normalize:
-    minus_log:
-      data_in: tomo
-      data_out: tomo
+- method: normalize
+  module_path: tomopy.prep.normalize
+  parameters:
+    cutoff: null
+    averaging: mean
+- method: minus_log
+  module_path: tomopy.prep.normalize
+  parameters: {}
 --- # main processing
-- tomopy.prep.stripe:
-    remove_stripe_fw:
-      data_in: tomo
-      data_out: tomo
-      level: null
-      wname: db5
-      sigma: 2
-      pad: true
-- tomopy.recon.rotation:
-    find_center_vo:
-      data_in: tomo
-      data_out: cor
-      ind: mid
-      smin: -50
-      smax: 50
-      srad: 6
-      step: 0.25
-      ratio: 0.5
-      drop: 20
-- tomopy.recon.algorithm:
-    recon:
-      data_in: tomo
-      data_out: tomo
-      center: cor
-      sinogram_order: false
-      algorithm: gridrec
-      init_recon: null
-      save_result: true
-      #additional parameters': AVAILABLE
-- httomolib.misc.images:
-    save_to_images:
-      data_in: tomo
-      subfolder_name: images
-      axis: 1
-      file_format: tif
-      bits: 8
-      perc_range_min: 0.0
-      perc_range_max: 100.0
-      jpeg_quality: 95
-      glob_stats: true
+- method: remove_stripe_fw
+  module_path: tomopy.prep.stripe
+  parameters:
+    level: null
+    wname: db5
+    sigma: 2
+    pad: true
+- method: find_center_vo
+  module_path: tomopy.recon.rotation
+  parameters:
+    ind: mid
+    smin: -50
+    smax: 50
+    srad: 6
+    step: 0.25
+    ratio: 0.5
+    drop: 20
+  id: centering
+  side_outputs:
+    cor: centre_of_rotation
+- method: recon
+  module_path: tomopy.recon.algorithm
+  parameters:
+    center: ${{centering.side_outputs.centre_of_rotation}}
+    sinogram_order: false
+    algorithm: gridrec
+    init_recon: null
+    save_result: true
+    #additional parameters': AVAILABLE
+- method: save_to_images
+  module_path: httomolib.misc.images
+  parameters:
+    subfolder_name: images
+    axis: 1
+    file_format: tif
+    bits: 8
+    perc_range_min: 0.0
+    perc_range_max: 100.0
+    jpeg_quality: 95
+    glob_stats: true
 
diff --git a/samples/pipeline_template_examples/testing/more_than_one_method.yaml b/samples/pipeline_template_examples/testing/more_than_one_method.yaml
index baf91e48c..ed7f48c89 100644
--- a/samples/pipeline_template_examples/testing/more_than_one_method.yaml
+++ b/samples/pipeline_template_examples/testing/more_than_one_method.yaml
@@ -1,24 +1,30 @@
 --- # loading
-- httomo.data.hdf.loaders:
-    standard_tomo:
-      name: tomo
-      data_path: entry1/tomo_entry/data/data
-      image_key_path: entry1/tomo_entry/instrument/detector/image_key
+- method: standard_tomo
+  module_path: httomo.data.hdf.loaders
+  parameters:
+    name: tomo
+    in_file: tests/test_data/tomo_standard.nxs
+    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: [None, None, None]
+    pad: 0
 --- # pre-processing
-- tomopy.prep.normalize:
-    normalize:
-      data_in: tomo
-      data_out: tomo
-      cutoff: null
-    minus_log:
-      data_in: tomo
-      data_out: tomo
+- method: normalize
+  module_path: tomopy.prep.normalize
+  parameters:
+    cutoff: null
+    averaging: mean
+  method: minus_log # more than one method
+  module_path: tomopy.prep.normalize
+  parameters: {}
 --- # main processing
-- tomopy.prep.stripe:
-    remove_stripe_fw:
-      data_in: tomo
-      data_out: tomo
-      level: null
-      wname: db5
-      sigma: 2
-      pad: true
+- method: remove_stripe_fw
+  module_path: tomopy.prep.stripe
+  parameters:
+    level: null
+    wname: db5
+    sigma: 2
+    pad: true
diff --git a/samples/pipeline_template_examples/testing/required_param.yaml b/samples/pipeline_template_examples/testing/required_param.yaml
index 9f6f69c48..ddc0ff13e 100644
--- a/samples/pipeline_template_examples/testing/required_param.yaml
+++ b/samples/pipeline_template_examples/testing/required_param.yaml
@@ -1,25 +1,29 @@
 --- # loading
-- httomo.data.hdf.loaders:
-    standard_tomo:
-      name: tomo
-      data_path: /entry1/tomo_entry/data/data
-      image_key_path: /entry1/tomo_entry/instrument/detector/image_key
-      dimension: 1
-      pad: 0
+- method: standard_tomo
+  module_path: httomo.data.hdf.loaders
+  parameters:
+    name: tomo
+    in_file: tests/test_data/tomo_standard.nxs
+    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: [None, None, None]
+    pad: 0
 --- # pre-processing
-- tomopy.prep.normalize:
-    normalize:
-      data_in: tomo
-      data_out: tomo
-      cutoff: null
+- method: normalize
+  module_path: tomopy.prep.normalize
+  parameters:
+    cutoff: null
+    averaging: mean
 --- # main processing
-- tomopy.prep.stripe:
-    stripes_mask3d:
-      data_in: tomo
-      data_out: tomo
-      weights: REQUIRED
-      threshold: 0.6
-      min_stripe_length: 20
-      min_stripe_depth: 10
-      min_stripe_width: 5
-      sensitivity_perc: 85.0
+- method: stripes_mask3d
+  module_path: tomopy.prep.stripe
+  parameters:
+    weights: REQUIRED
+    threshold: 0.6
+    min_stripe_length: 20
+    min_stripe_depth: 10
+    min_stripe_width: 5
+    sensitivity_perc: 85.0
diff --git a/samples/pipeline_template_examples/testing/step_absent.yml b/samples/pipeline_template_examples/testing/step_absent.yml
index da1cc2e3d..226b9a782 100644
--- a/samples/pipeline_template_examples/testing/step_absent.yml
+++ b/samples/pipeline_template_examples/testing/step_absent.yml
@@ -1,17 +1,21 @@
-- httomo.data.hdf.loaders:
-    standard_tomo:
-      name: tomo
-      data_path: /entry1/tomo_entry/data/data
-      image_key_path: /entry1/tomo_entry/instrument/detector/image_key
-      dimension: 1
-      pad: 0
-- tomopy.recon.algorithm:
-    recon:
-      data_in: tomo
-      data_out: tomo
-      center: !SweepRange
-        start: 10
-        stop: 50
-      sinogram_order: false
-      algorithm: gridrec
-      init_recon: null
+- method: standard_tomo
+  module_path: httomo.data.hdf.loaders
+  parameters:
+    name: tomo
+    in_file: tests/test_data/tomo_standard.nxs
+    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: [None, None, None]
+    pad: 0
+- method: recon
+  module_path: tomopy.recon.algorithm
+  parameters:
+    center: !SweepRange
+      start: 10
+      stop: 50
+    sinogram_order: false
+    algorithm: gridrec
+    init_recon: null
diff --git a/samples/pipeline_template_examples/testing/sweep_testing_pipeline.yaml b/samples/pipeline_template_examples/testing/sweep_testing_pipeline.yaml
index 97ea60434..6e87be095 100644
--- a/samples/pipeline_template_examples/testing/sweep_testing_pipeline.yaml
+++ b/samples/pipeline_template_examples/testing/sweep_testing_pipeline.yaml
@@ -1,10 +1,15 @@
-- httomo.data.hdf.loaders:
-    standard_tomo:
-      name: tomo
-      data_path: /entry1/tomo_entry/data/data
-      image_key_path: /entry1/tomo_entry/instrument/detector/image_key
-      dimension: 1
-      pad: 0
+- method: standard_tomo
+  module_path: httomo.data.hdf.loaders
+  parameters:
+    name: tomo
+    in_file: tests/test_data/tomo_standard.nxs
+    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: [None, None, None]
+    pad: 0
 - tomopy.misc.corr:
     median_filter:
       data_in: tomo
@@ -13,8 +18,9 @@
         - 3
         - 5
       axis: 0
-- tomopy.prep.normalize:
-    normalize:
-      data_in: tomo
-      data_out: tomo
-      cutoff: 10.0
+- method: normalize
+  module_path: tomopy.prep.normalize
+  parameters:
+    cutoff: 10.0
+    averaging: mean
+
diff --git a/samples/pipeline_template_examples/testing/testing_pipeline.yaml b/samples/pipeline_template_examples/testing/testing_pipeline.yaml
index 5ce7656d3..6577495f9 100644
--- a/samples/pipeline_template_examples/testing/testing_pipeline.yaml
+++ b/samples/pipeline_template_examples/testing/testing_pipeline.yaml
@@ -1,51 +1,46 @@
---- # pre-processing
-- tomopy.recon.rotation:
-    find_center_vo:
-      data_in: tomo
-      data_out: cor
-      ind: mid
-      smin: -50
-      smax: 50
-      srad: 6
-      step: 0.25
-      ratio: 0.5
-      drop: 20
---- # main processing
-- tomopy.prep.normalize:
-    normalize:
-      data_in: tomo
-      data_out: tomo
-      cutoff: null
-- tomopy.prep.normalize:
-    minus_log:
-      data_in: tomo
-      data_out: tomo
-- tomopy.prep.stripe:
-    remove_stripe_fw:
-      data_in: tomo
-      data_out: tomo
-      level: null
-      wname: db5
-      sigma: 2
-      pad: true
-- tomopy.recon.algorithm:
-    recon:
-      data_in: tomo
-      data_out: tomo
-      center: cor
-      sinogram_order: false
-      algorithm: gridrec
-      init_recon: null
-      save_result: true
-      #additional parameters': AVAILABLE
-- httomolib.misc.images:
-    save_to_images:
-      data_in: tomo
-      subfolder_name: images
-      axis: 1
-      file_format: tif
-      bits: 8
-      perc_range_min: 0.0
-      perc_range_max: 100.0
-      jpeg_quality: 95
-      glob_stats: true
+- method: normalize
+  module_path: tomopy.prep.normalize
+  parameters:
+    cutoff: null
+    averaging: mean
+- method: minus_log
+  module_path: tomopy.prep.normalize
+  parameters: {}
+- method: find_center_vo
+  module_path: tomopy.recon.rotation
+  parameters:
+    ind: mid
+    smin: -50
+    smax: 50
+    srad: 6
+    step: 0.25
+    ratio: 0.5
+    drop: 20
+  id: centering
+  side_outputs:
+    cor: centre_of_rotation  
+- method: remove_stripe_fw
+  module_path: tomopy.prep.stripe
+  parameters:
+    level: null
+    wname: db5
+    sigma: 2
+    pad: true
+- method: recon
+  module_path: tomopy.recon.algorithm
+  parameters:
+    center: ${{centering.side_outputs.centre_of_rotation}}
+    sinogram_order: false
+    algorithm: gridrec
+    init_recon: null
+  save_result: true
+- method: save_to_images
+  module_path: httomolib.misc.images
+  parameters:
+    subfolder_name: images
+    axis: 1
+    file_format: tif
+    bits: 8
+    perc_range_min: 0.0
+    perc_range_max: 100.0
+    jpeg_quality: 95
diff --git a/samples/pipeline_template_examples/testing/wrong_indentation_pipeline.yaml b/samples/pipeline_template_examples/testing/wrong_indentation_pipeline.yaml
index a559bee6f..cd9b62c38 100644
--- a/samples/pipeline_template_examples/testing/wrong_indentation_pipeline.yaml
+++ b/samples/pipeline_template_examples/testing/wrong_indentation_pipeline.yaml
@@ -1,7 +1,12 @@
-- httomo.data.hdf.loaders:
-    standard_tomo:
-        name: tomo
+- method: standard_tomo
+  module_path: httomo.data.hdf.loaders
+  parameters:
+    name: tomo
+      in_file: tests/test_data/tomo_standard.nxs
       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: [None, None, None]
       pad: 0
diff --git a/tests/conftest.py b/tests/conftest.py
index 5c5e73184..e54f5fc12 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -8,13 +8,12 @@
 
 import pytest
 import yaml
-
-from httomo.yaml_loader import YamlLoader
+from httomo.runner.dataset import DataSet
+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")
@@ -56,6 +55,7 @@ def output_folder():
                 path.unlink()
             elif path.is_dir():
                 rmtree(path)
+    return str(Path("output_dir").resolve())
 
 
 @pytest.fixture
@@ -66,9 +66,6 @@ def cmd():
         "httomo",
         "run",
         "--save-all",
-        "--ncore",
-        "2",
-        "output_dir/",
     ]
 
 
@@ -76,6 +73,9 @@ 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():
@@ -115,6 +115,25 @@ 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"]))
@@ -166,6 +185,11 @@ def diad_loader():
     return "samples/loader_configs/diad.yaml"
 
 
+@pytest.fixture
+def diad_pipeline_gpu():
+    return "samples/pipeline_template_examples/DLS/01_diad_pipeline_gpu.yaml"
+
+
 @pytest.fixture
 def i12_data():
     return "tests/test_data/i12/separate_flats_darks/i12_dynamic_start_stop180.nxs"
@@ -200,27 +224,64 @@ 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_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 = []
+        data : list = []
         for y in yamls:
-            with open(y, "r") as file_descriptor:
-                data.extend(yaml.load_all(file_descriptor, Loader=yaml.SafeLoader))
+            curr_yaml_list = _yaml_loader(y)[0]
+            for x in curr_yaml_list:
+                data.append(x)
         with open("temp.yaml", "w") as file_descriptor:
-            yaml.dump_all(data, file_descriptor)
-
+            yaml.dump(data, file_descriptor)
     return _merge_yamls
 
-
 @pytest.fixture
-def yaml_loader() -> type[YamlLoader]:
-    YamlLoader.add_constructor("!Sweep", YamlLoader.sweep_manual)
-    YamlLoader.add_constructor("!SweepRange", YamlLoader.sweep_range)
-    return YamlLoader
+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)),
+    )
diff --git a/tests/runner/__init__.py b/tests/runner/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/runner/test_block_split.py b/tests/runner/test_block_split.py
new file mode 100644
index 000000000..b4ea28b22
--- /dev/null
+++ b/tests/runner/test_block_split.py
@@ -0,0 +1,300 @@
+import numpy as np
+import pytest
+from httomo.runner.block_split import BlockAggregator, BlockSplitter
+from httomo.runner.dataset import DataSet
+from httomo.utils import Pattern, gpu_enabled, xp
+
+
+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
+    )
+
+    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
+    )
+
+    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)
+
+    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, :, :]
+    )
+    np.testing.assert_array_equal(
+        splitter[1].data, dummy_dataset.data[max_slices:, :, :]
+    )
+
+
+def test_block_gives_blocks_sino(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.sinogram, 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:, :]
+    )
+
+
+def test_block_gives_blocks_odd(dummy_dataset: DataSet):
+    dummy_dataset.data = np.random.random(dummy_dataset.data.shape).astype(np.float32)
+
+    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, :, :]
+    )
+    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, :, :]
+        )
+
+
+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 "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
+    )
+    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)
+
+    with pytest.raises(ValueError) as e:
+        aggregator.append(dummy_dataset)
+
+    assert "only 0 slices left" in str(e)
+
+
+@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
+
+    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)
+
+
+def test_can_aggregate_changed_dimensions(dummy_dataset: DataSet):
+    splitter = BlockSplitter(
+        dummy_dataset, Pattern.projection, dummy_dataset.data.shape[0] // 2
+    )
+    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
+
+    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)
+
+
+def test_changing_dimensions_in_second_block_fails(dummy_dataset: DataSet):
+    splitter = BlockSplitter(
+        dummy_dataset, Pattern.projection, dummy_dataset.data.shape[0] // 2
+    )
+    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],
+        ),
+    )
+
+    splitter = BlockSplitter(
+        dummy_dataset, Pattern.projection, dummy_dataset.data.shape[0] // 2
+    )
+    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
+
+    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
new file mode 100644
index 000000000..463ef5007
--- /dev/null
+++ b/tests/runner/test_dataset.py
@@ -0,0 +1,315 @@
+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_gpu_utils.py b/tests/runner/test_gpu_utils.py
new file mode 100644
index 000000000..21de49ab3
--- /dev/null
+++ b/tests/runner/test_gpu_utils.py
@@ -0,0 +1,25 @@
+import pytest
+from pytest_mock import MockerFixture
+from httomo.data.mpiutil import local_rank
+from httomo.runner.gpu_utils import get_available_gpu_memory
+from httomo.utils import xp, gpu_enabled
+
+
+@pytest.mark.skipif(
+    not gpu_enabled or xp.cuda.runtime.getDeviceCount() == 0,
+    reason="skipped as cupy is not available",
+)
+def test_get_available_memory():
+    mem = get_available_gpu_memory(10.0)
+
+    assert mem > 0
+    assert mem <= 0.9 * xp.cuda.Device(local_rank).mem_info[0]
+
+
+
+def test_get_available_memory_cpu(mocker: MockerFixture):
+    # this function is called in the implementation try block -
+    # we trigger an import error here to simulate cupy not being there
+    mocker.patch("cupy.cuda.Device", side_effect=ImportError("can't use cupy"))
+    # greater 10GB
+    assert get_available_gpu_memory() > 10e9
diff --git a/tests/runner/test_loader.py b/tests/runner/test_loader.py
new file mode 100644
index 000000000..f2b8437a2
--- /dev/null
+++ b/tests/runner/test_loader.py
@@ -0,0 +1,60 @@
+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
new file mode 100644
index 000000000..fa2607faf
--- /dev/null
+++ b/tests/runner/test_output_ref.py
@@ -0,0 +1,24 @@
+import pytest
+from pytest_mock import MockerFixture
+
+from httomo.runner.output_ref import OutputRef
+
+from .testing_utils import make_test_method
+
+
+def test_gets_mapped_value(mocker: MockerFixture):
+    m = make_test_method(mocker)
+    m.get_side_output.return_value = {"output": 42}
+    ref = OutputRef(m, "output")
+    assert ref.value == 42
+
+
+def test_throws_if_not_mapped(mocker: MockerFixture):
+    m = make_test_method(mocker)
+    m.get_side_output.return_value = {"output": 42, "xxx": [1, 2, 3]}
+    ref = OutputRef(m, "unknown_output")
+    with pytest.raises(ValueError) as e:
+        ref.value
+    assert "not found in" in str(e)
+    assert "unknown_output" in str(e)
+    assert "are known: output, xxx"
diff --git a/tests/runner/test_pipeline.py b/tests/runner/test_pipeline.py
new file mode 100644
index 000000000..f0fec6227
--- /dev/null
+++ b/tests/runner/test_pipeline.py
@@ -0,0 +1,41 @@
+from pytest_mock import MockerFixture
+
+from httomo.runner.pipeline import Pipeline
+from httomo.utils import Pattern
+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):
+    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"),
+        ],
+    )
+
+    assert len(p) == 3
+    for i, m in enumerate(p):
+        assert m.method_name == f"m{i+1}"
diff --git a/tests/runner/test_platform_section.py b/tests/runner/test_platform_section.py
new file mode 100644
index 000000000..fc2d84e62
--- /dev/null
+++ b/tests/runner/test_platform_section.py
@@ -0,0 +1,310 @@
+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_task_runner.py b/tests/runner/test_task_runner.py
new file mode 100644
index 000000000..a97c24d1d
--- /dev/null
+++ b/tests/runner/test_task_runner.py
@@ -0,0 +1,376 @@
+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
+from httomo.runner.methods_repository_interface import GpuMemoryRequirement
+from httomo.runner.pipeline import Pipeline
+from httomo.runner.platform_section import 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
+
+
+def test_check_params_for_sweep_raises_exception(mocker: MockerFixture):
+    p = Pipeline(
+        loader=make_test_loader(mocker),
+        methods=[
+            make_test_method(
+                mocker,
+                testparam=(
+                    1,
+                    2,
+                ),
+            )
+        ],
+        save_results_set=[False],
+    )
+    t = TaskRunner(p)
+    with pytest.raises(ValueError) as e:
+        t._check_params_for_sweep()
+
+
+def test_can_load_datasets(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
+    p = Pipeline(
+        loader=loader, methods=[make_test_method(mocker)], save_results_set=[False]
+    )
+    t = TaskRunner(p)
+    t._prepare()
+
+    loader.load.assert_called()
+    assert t.dataset is not None
+    np.testing.assert_array_equal(t.dataset.data, 1)
+
+
+@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)
+    t._prepare()
+    s = sectionize(p, False)
+    assert s[0].gpu is True
+
+    t.determine_max_slices(s[0], 0)
+
+    assert s[0].max_slices == dataset.data.shape[0]
+
+
+@pytest.mark.skipif(
+    not gpu_enabled or xp.cuda.runtime.getDeviceCount() == 0,
+    reason="skipped as cupy is not available",
+)
+@pytest.mark.parametrize(
+    "max_slices_methods",
+    [
+        [3],
+        [5, 3, 19],
+    ],
+)
+def test_can_determine_max_slices_with_gpu_estimator(
+    mocker: MockerFixture, dummy_dataset: DataSet, max_slices_methods: List[int]
+):
+    loader = make_test_loader(mocker)
+    loader.load.return_value = dummy_dataset
+    methods = []
+    save_results_list = []
+    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)
+        methods.append(method)
+        save_results_list.append(False)
+    p = Pipeline(loader=loader, methods=methods, save_results_set=save_results_list)
+    t = TaskRunner(p)
+    t._prepare()
+    s = sectionize(p, False)
+    assert s[0].gpu is True
+    assert len(s[0]) == len(max_slices_methods)
+    shape = list(dummy_dataset.data.shape)
+    shape.pop(0)
+
+    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)
+
+
+def test_can_determine_max_slices_with_cpu(
+    mocker: MockerFixture, dummy_dataset: DataSet
+):
+    loader = make_test_loader(mocker)
+    loader.load.return_value = dummy_dataset
+    methods = []
+    for i 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)
+    t._prepare()
+    s = sectionize(p, False)
+    assert s[0].gpu is False
+
+    t.determine_max_slices(s[0], 0)
+    assert s[0].max_slices == dummy_dataset.data.shape[0]
+
+
+def test_calls_update_side_inputs_after_call(
+    mocker: MockerFixture, dummy_dataset: DataSet
+):
+    loader = make_test_loader(mocker)
+    loader.load.return_value = dummy_dataset
+
+    side_outputs = {"answer": 42, "other": "xxx"}
+
+    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]
+    )
+    t = TaskRunner(p)
+    spy = mocker.patch.object(t, "update_side_inputs")
+    t._prepare()
+    t._execute_method(method1, 2, dummy_dataset)
+
+    spy.assert_called_with(side_outputs)
+    t.side_outputs == side_outputs
+
+
+def test_update_side_inputs_updates_downstream_methods(mocker: MockerFixture):
+    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"]
+    method3 = make_test_method(mocker, method_name="m3")
+    method3.parameters = ["answer", "other", "whatever"]
+
+    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)
+
+    method2.__setitem__.assert_called_with("answer", 42)
+    method3_calls = [call("answer", 42), call("other", "xxx")]
+    method3.__setitem__.assert_has_calls(method3_calls)
+
+
+def test_execute_section_calls_blockwise_execute(
+    mocker: MockerFixture, dummy_dataset: DataSet
+):
+    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
+    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)
+    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
+
+    # make this function return the block, with data multiplied by 2
+    def mul_block_by_two(section, block: DataSet):
+        block.data *= 2
+        return block
+
+    block_mock = mocker.patch.object(
+        t, "_execute_section_block", side_effect=mul_block_by_two
+    )
+
+    t._execute_section(s[0], 1)
+
+    np.testing.assert_allclose(t.dataset.data, original_value * 2)
+    calls = [call(ANY, ANY), call(ANY, ANY)]
+    block_mock.assert_has_calls(calls)  # make sure we got called twice
+
+
+def test_execute_section_for_block(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")
+    p = Pipeline(
+        loader=loader, methods=[method1, method2], save_results_set=[False, False]
+    )
+    s = sectionize(p, False)
+    t = TaskRunner(p)
+    t._prepare()
+    exec_method = mocker.patch.object(t, "_execute_method", return_value=dummy_dataset)
+    t._execute_section_block(s[0], dummy_dataset)
+
+    calls = [call(method1, ANY, ANY), call(method2, ANY, 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
+):
+    loader = make_test_loader(mocker)
+    loader.load.return_value = dummy_dataset
+    method1 = make_test_method(mocker, method_name="m1", pattern=Pattern.projection)
+    method2 = make_test_method(mocker, method_name="m2", pattern=Pattern.sinogram)
+    p = Pipeline(
+        loader=loader, methods=[method1, method2], save_results_set=[False, False]
+    )
+    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")
+
+    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)
+
+
+def test_warns_after_multiple_reslices(mocker: MockerFixture, dummy_dataset: DataSet):
+    loader = make_test_loader(mocker)
+    loader.load.return_value = dummy_dataset
+    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)
+
+    exec_section = mocker.patch.object(t, "_execute_section")
+    reslice = mocker.patch("httomo.runner.task_runner.reslice")
+
+    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)
+
+    exec_section = mocker.patch.object(t, "_execute_section")
+    reslice = mocker.patch("httomo.runner.task_runner.reslice")
+
+    t.execute()
+
+    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(
+    mocker: MockerFixture,
+    dummy_dataset: DataSet,
+    method_name: str,
+    dim: int,
+    save: bool,
+):
+    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(
+        mocker,
+        method_name=method_name,
+        module_path="path1",
+    )
+    method1.recon_algorithm = "testalgo"
+    method2 = make_test_method(mocker, method_name="m2")
+    p = Pipeline(
+        loader=loader, methods=[method1, method2], save_results_set=[True, False]
+    )
+    t = TaskRunner(p)
+
+    exec_section = mocker.patch.object(t, "_execute_section")
+    intermediate_save = mocker.patch("httomo.runner.task_runner.intermediate_dataset")
+
+    t.execute()
+
+    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()
diff --git a/tests/runner/test_wrappers.py b/tests/runner/test_wrappers.py
new file mode 100644
index 000000000..a4202933a
--- /dev/null
+++ b/tests/runner/test_wrappers.py
@@ -0,0 +1,1069 @@
+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
new file mode 100644
index 000000000..04921d8ec
--- /dev/null
+++ b/tests/runner/testing_utils.py
@@ -0,0 +1,59 @@
+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 810bc6106..0adbaa247 100644
--- a/tests/test_backends/test_httomolibgpu.py
+++ b/tests/test_backends/test_httomolibgpu.py
@@ -313,7 +313,6 @@ def test_recon_SIRT_memoryhook(slices, ensure_clean_memory):
                     recon_size=recon_size,
                     iterations=2,
                     nonnegativity=True,
-                    recon_mask_radius = 0.8,
                 )
 
     # make sure estimator function is within range (80% min, 100% max)
@@ -348,7 +347,6 @@ def test_recon_CGLS_memoryhook(slices, ensure_clean_memory):
                     recon_size=recon_size,
                     iterations=2,
                     nonnegativity=True,
-                    recon_mask_radius = 0.8,
                 )
 
     # make sure estimator function is within range (80% min, 100% max)
diff --git a/tests/test_cli.py b/tests/test_cli.py
index e1b0bba97..b3b7d2b31 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -21,15 +21,17 @@ def test_cli_help_shows_help():
         .startswith("Usage: python -m httomo")
     )
 
-
+"""
+# TODO possibly re-enable after yaml checker is complete
 def test_cli_noargs_raises_error():
     cmd = [sys.executable, "-m", "httomo"]
     try:
         subprocess.check_output(cmd)
     except subprocess.CalledProcessError as e:
         assert e.returncode == 2
+"""
 
-
+"""
 def test_cli_check_pass_data_file(standard_loader, standard_data):
     cmd = [sys.executable, "-m", "httomo", "check", standard_loader, standard_data]
     check_data_str = (
@@ -37,8 +39,9 @@ def test_cli_check_pass_data_file(standard_loader, standard_data):
         "match the paths and keys in the input file (IN_DATA)..."
     )
     assert check_data_str in subprocess.check_output(cmd).decode().strip()
+"""
 
-
+"""
 def test_cli_pass_output_folder(
     standard_data, standard_loader, testing_pipeline, merge_yamls, output_folder
 ):
@@ -59,14 +62,16 @@ def test_cli_pass_output_folder(
     ]
     subprocess.check_output(cmd)
     assert Path(custom_output_dir, "user.log").exists()
-
+"""
 
 @pytest.mark.cupy
-def test_cli_pass_gpu_id(cmd, standard_data, standard_loader, output_folder):
-    cmd.insert(7, standard_data)
-    cmd.insert(8, standard_loader)
-    cmd.insert(4, "--gpu-id")
-    cmd.insert(5, "10")
+def test_cli_pass_gpu_id(cmd, standard_data, standard_loader):
+    output_dir = "output_dir"  # dir created by the `output_folder` fixture
+    cmd.insert(4, standard_data)
+    cmd.insert(5, standard_loader)
+    cmd.insert(6, output_dir)
+    cmd.insert(7, "--gpu-id")
+    cmd.insert(8, "100")
 
     result = subprocess.run(
         cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
diff --git a/tests/test_data/360scan/360scan.hdf b/tests/test_data/360scan/360scan.hdf
new file mode 100644
index 000000000..7368e1c91
Binary files /dev/null and b/tests/test_data/360scan/360scan.hdf differ
diff --git a/tests/test_loader.py b/tests/test_loader.py
index e02eecb2c..29a1e9592 100644
--- a/tests/test_loader.py
+++ b/tests/test_loader.py
@@ -9,14 +9,14 @@
 comm = MPI.COMM_WORLD
 
 
-
 def test_tomo_standard_testing_pipeline_loaded(
     cmd, standard_data, standard_loader, output_folder, testing_pipeline, merge_yamls
 ):
     cmd.pop(4)  #: don't save all
-    cmd.insert(6, standard_data)
+    cmd.insert(6, standard_data)    
     merge_yamls(standard_loader, testing_pipeline)
     cmd.insert(7, "temp.yaml")
+    cmd.insert(8, output_folder)
 
     result = subprocess.run(
         cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
@@ -25,10 +25,10 @@ def test_tomo_standard_testing_pipeline_loaded(
     assert "Running task 1 (pattern=projection): standard_tomo..." in result.stderr
     assert "Running task 2 (pattern=projection): normalize..." in result.stderr
     assert "Running task 3 (pattern=projection): minus_log.." in result.stderr
-    assert "Running task 4 (pattern=sinogram): remove_stripe_fw..." in result.stderr
-    assert "Running task 6 (pattern=all): save_to_images.." in result.stderr
-    assert "Pipeline finished" in result.stderr
-
+    assert "Running task 4 (pattern=projection): find_center_vo.." in result.stderr
+    assert "Running task 5 (pattern=sinogram): remove_stripe_fw..." in result.stderr
+    assert "Running task 6 (pattern=sinogram): recon..." in result.stderr
+    assert "Running task 7 (pattern=sinogram): save_to_images.." in result.stderr    
 
 def test_diad_testing_pipeline_loaded(
     cmd, diad_data, diad_loader, output_folder, testing_pipeline, merge_yamls
@@ -36,6 +36,7 @@ def test_diad_testing_pipeline_loaded(
     cmd.insert(7, diad_data)
     merge_yamls(diad_loader, testing_pipeline)
     cmd.insert(8, "temp.yaml")
+    cmd.insert(8, output_folder)
 
     result = subprocess.run(
         cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
@@ -43,10 +44,10 @@ def test_diad_testing_pipeline_loaded(
     assert "Running task 1 (pattern=projection): standard_tomo..." in result.stderr
     assert "Running task 2 (pattern=projection): normalize..." in result.stderr
     assert "Running task 3 (pattern=projection): minus_log.." in result.stderr
-    assert "Running task 4 (pattern=sinogram): remove_stripe_fw..." in result.stderr
-    assert "Running task 6 (pattern=all): save_to_images.." in result.stderr
-    assert "Pipeline finished" in result.stderr
-
+    assert "Running task 4 (pattern=projection): find_center_vo.." in result.stderr
+    assert "Running task 5 (pattern=sinogram): remove_stripe_fw..." in result.stderr
+    assert "Running task 6 (pattern=sinogram): recon..." in result.stderr
+    assert "Running task 7 (pattern=sinogram): save_to_images.." in result.stderr    
 
 def test_standard_tomo(standard_data, standard_data_path, standard_image_key_path):
     preview = [None, {"start": 5, "stop": 10}, None]
diff --git a/tests/test_method_query.py b/tests/test_method_query.py
index 418529a5b..ba99bc52c 100644
--- a/tests/test_method_query.py
+++ b/tests/test_method_query.py
@@ -1,5 +1,8 @@
-from httomo.methods_database.query import get_method_info
+from pytest_mock import MockerFixture
+from httomo.methods_database.query import MethodsDatabaseQuery, get_method_info
 import pytest
+import numpy as np
+from httomo.utils import Pattern
 
 
 def test_get_from_tomopy():
@@ -33,13 +36,82 @@ def test_httomolibgpu_pattern():
 
 
 def test_httomolibgpu_implementation():
-    implementation  =  get_method_info("httomolibgpu.prep.normalize", "normalize", "implementation")
+    implementation = get_method_info(
+        "httomolibgpu.prep.normalize", "normalize", "implementation"
+    )
     assert implementation == "gpu_cupy"
 
+
 def test_httomolibgpu_output_dims_change():
-    output_dims_change  =  get_method_info("httomolibgpu.prep.normalize", "normalize", "output_dims_change")
+    output_dims_change = get_method_info(
+        "httomolibgpu.prep.normalize", "normalize", "output_dims_change"
+    )
     assert output_dims_change == False
 
+
 def test_httomolibgpu_memory_gpu():
-    memory_gpu  =  get_method_info("httomolibgpu.prep.normalize", "normalize", "memory_gpu")
-    assert len(memory_gpu) == 3
\ No newline at end of file
+    memory_gpu = get_method_info(
+        "httomolibgpu.prep.normalize", "normalize", "memory_gpu"
+    )
+    assert len(memory_gpu) == 3
+
+
+def test_database_query_object():
+    query = MethodsDatabaseQuery("httomolibgpu.prep.normalize", "normalize")
+    assert query.get_pattern() == Pattern.projection
+    assert query.get_output_dims_change() is False
+    assert query.get_implementation() == "gpu_cupy"
+    assert query.swap_dims_on_output() 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)
+
+
+def test_database_query_object_recon_swap_output():
+    query = MethodsDatabaseQuery("tomopy.recon.algorithm", "recon")
+    assert query.swap_dims_on_output() is True
+
+
+def test_database_query_calculate_memory(mocker: MockerFixture):
+    class FakeModule:
+        def _calc_memory_bytes_testmethod(non_slice_dims_shape, dtype, testparam):
+            assert non_slice_dims_shape == (
+                42,
+                3,
+            )
+            assert dtype == np.float32
+            assert testparam == 42.0
+            return 10, 20
+
+    importmock = mocker.patch("importlib.import_module", return_value=FakeModule)
+    query = MethodsDatabaseQuery("sample.module.path", "testmethod")
+
+    mem = query.calculate_memory_bytes((42, 3), np.float32, testparam=42.0)
+
+    importmock.assert_called_with(
+        "httomo.methods_database.packages.external.sample.supporting_funcs.module.path"
+    )
+    assert mem == (10, 20)
+
+
+def test_database_query_calculate_output_dims(mocker: MockerFixture):
+    class FakeModule:
+        def _calc_output_dim_testmethod(non_slice_dims_shape, testparam):
+            assert non_slice_dims_shape == (
+                42,
+                3,
+            )
+            assert testparam == 42.0
+            return 10, 20
+
+    importmock = mocker.patch("importlib.import_module", return_value=FakeModule)
+    query = MethodsDatabaseQuery("sample.module.path", "testmethod")
+
+    dims = query.calculate_output_dims((42, 3), testparam=42.0)
+
+    importmock.assert_called_with(
+        "httomo.methods_database.packages.external.sample.supporting_funcs.module.path"
+    )
+    assert dims == (10, 20)
diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py
index 1ed07c279..ac6cd1d7a 100644
--- a/tests/test_pipeline.py
+++ b/tests/test_pipeline.py
@@ -19,7 +19,7 @@ def _get_log_contents(file):
         log_contents = f.read()
 
     #: check that the generated log file has no ansi escape sequence
-    assert not PATTERN.search(log_contents)
+    # assert not PATTERN.search(log_contents)
 
     return log_contents
 
@@ -47,6 +47,7 @@ def test_tomo_standard_testing_pipeline_output(
     cmd.insert(6, standard_data)
     merge_yamls(standard_loader, testing_pipeline)
     cmd.insert(7, "temp.yaml")
+    cmd.insert(8, output_folder)
     subprocess.check_output(cmd)
 
     # recurse through output_dir and check that all files are there
@@ -74,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"]), -2.123908e-06, atol=1e-6)
-                assert_allclose(np.sum(f["data"]), -0.163116, atol=1e-6)
+                assert_allclose(np.mean(f["data"]), 0.0015362317, atol=1e-6)
+                assert_allclose(np.sum(f["data"]), 117.9826, atol=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.
@@ -86,102 +87,376 @@ def test_tomo_standard_testing_pipeline_output(
 
     log_contents = _get_log_contents(log_files[0])
 
-    assert f"INFO | See the full log file at: {log_files[0]}" in log_contents
-    assert "DEBUG | The full dataset shape is (220, 128, 160)" in log_contents
-    assert "DEBUG | Loading data: tests/test_data/tomo_standard.nxs" in log_contents
-    assert "DEBUG | Path to data: entry1/tomo_entry/data/data" in log_contents
-    assert "DEBUG | Preview: (0:180, 0:3:, :)" in log_contents
-    assert (
-        "DEBUG | RANK: [0], Data shape is (180, 3, 160) of type uint16" in log_contents
-    )
-    assert "DEBUG | <-------Reslicing/rechunking the data-------->" in log_contents
-    assert "DEBUG | Reslicing not necessary, as there is only one process" in log_contents
-    assert "INFO | ~~~ Pipeline finished ~~~" in log_contents
+    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 "Preview: (0:180, 57:60:, :)" 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_tomo_standard_testing_pipeline_output_with_save_all(
-    cmd, standard_data, standard_loader, testing_pipeline, output_folder, merge_yamls
-):
-    cmd.insert(7, standard_data)
-    merge_yamls(standard_loader, testing_pipeline)
-    cmd.insert(8, "temp.yaml")
+def test_run_pipeline_cpu1_yaml(cmd, standard_data, yaml_cpu_pipeline1, output_folder):
+    cmd.pop(4)  #: don't save all
+    cmd.insert(6, standard_data)
+    cmd.insert(7, yaml_cpu_pipeline1)
+    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) == 9
+    assert len(files) == 130
 
-    # check that the contents of the copied YAML in the output directory matches
-    # the contents of the input YAML
-    copied_yaml_path = list(filter(lambda x: ".yaml" in x, files)).pop()
-    assert compare_two_yamls("temp.yaml", copied_yaml_path)
+    # 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)
+
+    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 "Preview: (0:180, :, :)" 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):
+    cmd.pop(4)  #: don't save all
+    cmd.insert(6, standard_data)
+    cmd.insert(7, python_cpu_pipeline1)
+    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) == 130
 
     # check the .tif files
     tif_files = list(filter(lambda x: ".tif" in x, files))
-    assert len(tif_files) == 3
+    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)
+
+    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 "Preview: (0:180, :, :)" 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):
+    cmd.pop(4)  #: don't save all
+    cmd.insert(6, standard_data)
+    cmd.insert(7, yaml_cpu_pipeline2)
+    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) == 33
+
+    # check the .tif files
+    tif_files = list(filter(lambda x: ".tif" in x, files))
+    assert len(tif_files) == 30
+    #: check that the image size is correct
+    imarray = np.array(Image.open(tif_files[0]))
+    assert imarray.shape == (160, 160)
+
+    log_files = list(filter(lambda x: ".log" in x, files))
+    assert len(log_files) == 1
 
     #: check the generated h5 files
     h5_files = list(filter(lambda x: ".h5" in x, files))
-    assert len(h5_files) == 4
+    assert len(h5_files) == 1
 
     for file_to_open in h5_files:
-        if "tomopy-normalize-tomo.h5" in file_to_open:
+        if "tomopy-recon-tomo-gridrec.h5" in file_to_open:
             with h5py.File(file_to_open, "r") as f:
-                assert f["data"].shape == (180, 3, 160)
+                assert f["data"].shape == (160, 30, 160)
                 assert f["data"].dtype == np.float32
-                assert_allclose(np.mean(f["data"]), 1.004919, atol=1e-6)
-                assert_allclose(np.sum(f["data"]), 86824.984, atol=1e-6)
-        if "tomopy-minus_log-tomo.h5" in file_to_open:
-            with h5py.File(file_to_open, "r") as f:
-                assert f["data"].shape == (180, 3, 160)
-                assert_allclose(np.mean(f["data"]), -0.004374, atol=1e-6)
-                assert_allclose(np.sum(f["data"]), -377.88608, atol=1e-6)
-        if "tomopy-remove_stripe_fw-tomo.h5" in file_to_open:
+                assert_allclose(np.sum(f["data"]), 694.70306, atol=1e-6)
+
+    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 "Preview: (0:180, 30:60:, :)" 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):
+    cmd.pop(4)  #: don't save all
+    cmd.insert(6, standard_data)
+    cmd.insert(7, python_cpu_pipeline2)
+    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) == 33
+
+    # check the .tif files
+    tif_files = list(filter(lambda x: ".tif" in x, files))
+    assert len(tif_files) == 30
+    #: 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
+
+    for file_to_open in h5_files:
+        if "tomopy-recon-tomo-gridrec.h5" in file_to_open:
             with h5py.File(file_to_open, "r") as f:
-                assert_allclose(np.mean(f["data"]), -0.004198, atol=1e-6)
-                np.testing.assert_almost_equal(np.sum(f["data"]), -362.73358, decimal=4)
+                assert f["data"].shape == (160, 30, 160)
+                assert f["data"].dtype == np.float32
+                assert_allclose(np.sum(f["data"]), 694.70306, atol=1e-6)
 
+    log_files = list(filter(lambda x: ".log" in x, files))
+    assert len(log_files) == 1
 
-@pytest.mark.cupy
-def test_gpu_pipeline_output_with_save_all(
-    cmd, standard_data, gpu_pipeline, output_folder
-):
-    cmd.insert(7, standard_data)
-    cmd.insert(8, gpu_pipeline)
+    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 "Preview: (0:180, 30:60:, :)" 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):
+    cmd.pop(4)  #: don't save all
+    cmd.insert(6, standard_data)
+    cmd.insert(7, yaml_cpu_pipeline3)
+    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) == 133
-    
-    # commenting this until we sort out statistics calculation
+    assert len(files) == 130
+
+    # check the .tif files
     tif_files = list(filter(lambda x: ".tif" in x, files))
     assert len(tif_files) == 128
-    total_sum = 0
-    for i in range(128):
-        arr = np.array(Image.open(tif_files[i]))
-        assert arr.dtype == np.uint8
-        assert arr.shape == (160, 160)
-        total_sum += arr.sum()
+    #: 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) == 0
+
+    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 "Preview: (0:180, :, :)" 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 total_sum == 185989420.0
 
+def test_run_pipeline_cpu3_py(cmd, standard_data, python_cpu_pipeline3, output_folder):
+    cmd.pop(4)  #: don't save all
+    cmd.insert(6, standard_data)
+    cmd.insert(7, python_cpu_pipeline3)
+    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) == 130
+
+    # 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) == 3
+    assert len(h5_files) == 0
 
-    normalize_tomo = list(filter(lambda x: "normalize-tomo.h5" in x, h5_files))[0]
-    remove_stripe_tomo = list(filter(lambda x: "remove_stripe_based_sorting-tomo.h5" in x, h5_files))[0]
-    fpb_recon_tomo = list(filter(lambda x: "FBP-tomo.h5" in x, h5_files))[0]
+    log_files = list(filter(lambda x: ".log" in x, files))
+    assert len(log_files) == 1
+    log_contents = _get_log_contents(log_files[0])
 
-    with h5py.File(normalize_tomo, "r") as f:
-        assert f["data"].shape == (180, 128, 160)
-        assert_allclose(np.sum(f["data"]), 1062695.375, atol=1e-5)
-        assert_allclose(np.mean(f["data"]), 0.288275, atol=1e-5)
-    with h5py.File(remove_stripe_tomo, "r") as f:
-        assert f["data"].shape == (180, 128, 160)
-        assert_allclose(np.sum(f["data"]), 1059103.125, atol=1e-5)
-        assert_allclose(np.mean(f["data"]), 0.287300, atol=1e-5)
-    with h5py.File(fpb_recon_tomo, "r") as f:
-        assert_allclose(np.sum(f["data"]), 2614.8481, atol=1e-5)
-        assert_allclose(np.mean(f["data"]), 0.000798, atol=1e-5)
+    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 "Preview: (0:180, :, :)" 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):
+    cmd.pop(4)  #: don't save all
+    cmd.insert(6, standard_data)
+    cmd.insert(7, yaml_gpu_pipeline1)
+    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
+
+    # 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
+
+    for file_to_open in h5_files:
+        if "httomolibgpu-FBP-tomo.h5" in file_to_open:
+            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)
+
+    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 "Preview: (0:180, :, :)" 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
+
+
+def test_run_pipeline_gpu1_py(cmd, standard_data, python_gpu_pipeline1, output_folder):
+    cmd.pop(4)  #: don't save all
+    cmd.insert(6, standard_data)
+    cmd.insert(7, python_gpu_pipeline1)
+    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
+
+    # 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
+
+    for file_to_open in h5_files:
+        if "httomolibgpu-FBP-tomo.h5" in file_to_open:
+            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)
+
+    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 "Preview: (0:180, :, :)" 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
+
+
+def test_tomo_standard_testing_pipeline_output_with_save_all(
+    cmd, standard_data, standard_loader, testing_pipeline, output_folder, merge_yamls
+):
+    cmd.insert(7, standard_data)
+    merge_yamls(standard_loader, testing_pipeline)
+    cmd.insert(8, "temp.yaml")
+    cmd.insert(9, output_folder)
+    subprocess.check_output(cmd)
+
+    files = read_folder("output_dir/")
+    assert len(files) == 9
+
+    # check that the contents of the copied YAML in the output directory matches
+    # the contents of the input YAML
+    copied_yaml_path = list(filter(lambda x: ".yaml" in x, files)).pop()
+    assert compare_two_yamls("temp.yaml", copied_yaml_path)
+
+    # check the .tif files
+    tif_files = list(filter(lambda x: ".tif" in x, files))
+    assert len(tif_files) == 3
+
+    #: check the generated h5 files
+    h5_files = list(filter(lambda x: ".h5" in x, files))
+    assert len(h5_files) == 4
+
+    for file_to_open in h5_files:
+        if "tomopy-recon-tomo-gridrec.h5" in file_to_open:
+            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)
 
 
 def test_i12_testing_pipeline_output(
@@ -190,6 +465,7 @@ def test_i12_testing_pipeline_output(
     cmd.insert(7, i12_data)
     merge_yamls(i12_loader, testing_pipeline)
     cmd.insert(8, "temp.yaml")
+    cmd.insert(9, output_folder)
     subprocess.check_output(cmd)
 
     files = read_folder("output_dir/")
@@ -203,14 +479,6 @@ def test_i12_testing_pipeline_output(
 
     tif_files = list(filter(lambda x: ".tif" in x, files))
     assert len(tif_files) == 10
-    # total_sum = 0
-    # for i in range(10):
-    #    arr = np.array(Image.open(tif_files[i]))
-    #    assert arr.dtype == np.uint8
-    #    assert arr.shape == (192, 192)
-    #    total_sum += arr.sum()
-
-    # assert total_sum == 25834244.0
 
     h5_files = list(filter(lambda x: ".h5" in x, files))
     assert len(h5_files) == 4
@@ -224,7 +492,7 @@ def test_i12_testing_pipeline_output(
 
     with h5py.File(gridrec_recon, "r") as f:
         assert f["data"].shape == (192, 10, 192)
-        assert_allclose(np.sum(f["data"]), 2157.0347, atol=1e-6)
+        assert_allclose(np.sum(f["data"]), 2157.03, atol=1e-2)
         assert_allclose(np.mean(f["data"]), 0.0058513316, atol=1e-6)
     with h5py.File(minus_log_tomo, "r") as f:
         assert_allclose(np.sum(f["data"]), 1756628.4, atol=1e-6)
@@ -238,20 +506,19 @@ def test_i12_testing_pipeline_output(
         assert_allclose(np.mean(f["data"]), 0.28308493, atol=1e-6)
 
     log_contents = _get_log_contents(log_files[0])
-    assert "DEBUG | The full dataset shape is (724, 10, 192)" in log_contents
+    assert "The full dataset shape is (724, 10, 192)" in log_contents
     assert (
-        "DEBUG | Loading data: tests/test_data/i12/separate_flats_darks/i12_dynamic_start_stop180.nxs"
+        "Loading data: tests/test_data/i12/separate_flats_darks/i12_dynamic_start_stop180.nxs"
         in log_contents
     )
-    assert "DEBUG | Path to data: /1-TempPlugin-tomo/data" in log_contents
-    assert "DEBUG | Preview: (0:724, :, :)" 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: 4-tomopy-remove_stripe_fw-tomo.h5" in log_contents
-    assert "The center of rotation for 180 degrees sinogram is 95.5" in log_contents
-    assert "Saving intermediate file: 5-tomopy-recon-tomo-gridrec.h5" in log_contents
-    assert "INFO | ~~~ Pipeline finished ~~~" 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(
@@ -265,6 +532,7 @@ def test_i12_testing_ignore_darks_flats_pipeline_output(
     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/")
@@ -283,20 +551,19 @@ def test_i12_testing_ignore_darks_flats_pipeline_output(
     assert len(h5_files) == 4
 
     log_contents = _get_log_contents(log_files[0])
-    assert "DEBUG | The full dataset shape is (724, 10, 192)" in log_contents
+    assert "The full dataset shape is (724, 10, 192)" in log_contents
     assert (
-        "DEBUG | Loading data: tests/test_data/i12/separate_flats_darks/i12_dynamic_start_stop180.nxs"
+        "Loading data: tests/test_data/i12/separate_flats_darks/i12_dynamic_start_stop180.nxs"
         in log_contents
     )
-    assert "DEBUG | Path to data: /1-TempPlugin-tomo/data" in log_contents
-    assert "DEBUG | Preview: (0:724, :, :)" 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: 4-tomopy-remove_stripe_fw-tomo.h5" in log_contents
-    assert "The center of rotation for 180 degrees sinogram is 95.5" in log_contents
-    assert "Saving intermediate file: 5-tomopy-recon-tomo-gridrec.h5" in log_contents
-    assert "INFO | ~~~ Pipeline finished ~~~" 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_diad_testing_pipeline_output(
@@ -305,6 +572,7 @@ def test_diad_testing_pipeline_output(
     cmd.insert(7, diad_data)
     merge_yamls(diad_loader, testing_pipeline)
     cmd.insert(8, "temp.yaml")
+    cmd.insert(9, output_folder)
     subprocess.check_output(cmd)
 
     files = read_folder("output_dir/")
@@ -337,187 +605,77 @@ def test_diad_testing_pipeline_output(
         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.000823, atol=1e-6)
-                assert_allclose(np.sum(f["data"]), -1.11251, atol=1e-6)
+                assert_allclose(np.mean(f["data"]), 0.005883, atol=1e-6)
+                assert_allclose(np.sum(f["data"]), 7.954298, atol=1e-6)
 
     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"INFO | See the full log file at: {log_files[0]}" in log_contents
-    assert "DEBUG | The full dataset shape is (3201, 22, 26)" in log_contents
-    assert (
-        "DEBUG | Loading data: tests/test_data/k11_diad/k11-18014.nxs" in log_contents
-    )
-    assert "DEBUG | Path to data: /entry/imaging/data" in log_contents
-    assert "DEBUG | Preview: (100:3101, 5:7:, :)" in log_contents
-    assert (
-        "DEBUG | RANK: [0], Data shape is (3001, 2, 26) of type uint16" in log_contents
-    )
-    assert (
-        "DEBUG | Saving intermediate file: 2-tomopy-normalize-tomo.h5" in log_contents
-    )
-    assert (
-        "DEBUG | Reslicing not necessary, as there is only one process" in log_contents
-    )
-    assert "INFO | ~~~ Pipeline finished ~~~" in log_contents
-
-"""
-# commenting this one as the sweep functionality is not yet enabled for GPU and MPI run 
-# with GPU enabled is slightly trickier
-@pytest.mark.preview
-def test_sweep_pipeline_with_save_all_using_mpi(
-    cmd, standard_data, sample_pipelines, standard_loader, output_folder
-):
-    #: - - - - - - - - - - SERIAL RUN - - - - - - - - - - - - - - - - -
-    pipeline = sample_pipelines + "testing/sweep_testing_pipeline.yaml"
-    cmd.insert(4, "--output-folder")
-    cmd.insert(5, "serial_run")
-    cmd.insert(9, standard_data)
-    cmd.insert(10, pipeline)
+    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 "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
+
+
+def test_run_diad_pipeline_gpu(cmd, diad_data, diad_pipeline_gpu, output_folder):
+    cmd.pop(4)  #: don't save all
+    cmd.insert(6, diad_data)
+    cmd.insert(7, diad_pipeline_gpu)
+    cmd.insert(8, output_folder)
     subprocess.check_output(cmd)
 
-    #: - - - - - - - - - -  PARALLEL RUN - - - - - - - - - - -
-    cmd[5] = "parallel_run"
-    local.cmd.mpirun("-n", "4", *cmd)
+    # recurse through output_dir and check that all files are there
+    files = read_folder("output_dir/")
+    assert len(files) == 10
 
-    #: - - - - - - - - - - SERIAL vs PARALLEL OUTPUT - - - - - - -
-    serial_files = read_folder("output_dir/serial_run/")
-    parallel_files = read_folder("output_dir/parallel_run/")
-    assert len(serial_files) == 6
-    assert len(parallel_files) == 6
+    #: check the generated h5 files
+    h5_files = list(filter(lambda x: ".h5" in x, files))
+    assert len(h5_files) == 1
 
-    serial_copied_yaml_path = list(filter(lambda x: ".yaml" in x, serial_files))[0]
-    parallel_copied_yaml_path = list(filter(lambda x: ".yaml" in x, parallel_files))[0]
-    assert compare_two_yamls(pipeline, serial_copied_yaml_path)
-    assert compare_two_yamls(pipeline, parallel_copied_yaml_path)
+    log_files = list(filter(lambda x: ".log" in x, files))
+    assert len(log_files) == 1
 
-    serial_tif_files = list(filter(lambda x: ".tif" in x, serial_files))
-    parallel_tif_files = list(filter(lambda x: ".tif" in x, parallel_files))
-    assert len(serial_tif_files) == 2
-    assert len(parallel_tif_files) == 2
+    log_contents = _get_log_contents(log_files[0])
 
-    #: check that the image size is correct
-    serial_zeroth_tif = list(filter(lambda x: "00000.tif" in x, serial_tif_files))[0]
-    imarray = np.array(Image.open(serial_zeroth_tif))
-    parallel_zeroth_tif = list(filter(lambda x: "00000.tif" in x, parallel_tif_files))[
-        0
-    ]
-    mpi_imarray = np.array(Image.open(parallel_zeroth_tif))
-    assert imarray.shape == (128, 160) == mpi_imarray.shape
-    SUM = 3855857
-    assert imarray.sum() == SUM
-    assert mpi_imarray.sum() == SUM
-
-    serial_first_tif = list(filter(lambda x: "00001.tif" in x, serial_tif_files))[0]
-    imarray = np.array(Image.open(serial_first_tif))
-    parallel_first_tif = list(filter(lambda x: "00001.tif" in x, parallel_tif_files))[0]
-    mpi_imarray = np.array(Image.open(parallel_first_tif))
-    assert imarray.shape == (128, 160) == mpi_imarray.shape
-    SUM = 3856477
-    assert imarray.sum() == SUM
-    assert mpi_imarray.sum() == SUM
-
-    serial_h5_files = list(filter(lambda x: ".h5" in x, serial_files))
-    parallel_h5_files = list(filter(lambda x: ".h5" in x, parallel_files))
-    assert len(serial_h5_files) == 2
-    assert len(parallel_h5_files) == 2
-
-    serial_median_filter_tomo = list(
-        filter(lambda x: "median_filter-tomo.h5" in x, serial_h5_files)
-    )[0]
-    parallel_median_filter_tomo = list(
-        filter(lambda x: "median_filter-tomo.h5" in x, parallel_h5_files)
-    )[0]
-    serial_normalize_tomo = list(
-        filter(lambda x: "normalize-tomo.h5" in x, serial_h5_files)
-    )[0]
-    parallel_normalize_tomo = list(
-        filter(lambda x: "normalize-tomo.h5" in x, parallel_h5_files)
-    )[0]
+    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 "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
 
-    with h5py.File(serial_median_filter_tomo, "r") as f, h5py.File(
-        parallel_median_filter_tomo, "r"
-    ) as f2:
-        assert (
-            f["/data/param_sweep_0"].shape
-            == (180, 128, 160)
-            == f2["/data/param_sweep_0"].shape
-        )
-        assert (
-            f["/data/param_sweep_0"].dtype
-            == np.float32
-            == f2["/data/param_sweep_0"].dtype
-        )
-
-        s = np.sum(f["/data/param_sweep_0"])
-        assert_allclose(s, 2981532700.0, atol=1e-6)
-        assert_allclose(np.sum(f2["/data/param_sweep_0"]), s, atol=1e-6)
-
-        m = np.mean(f["/data/param_sweep_0"])
-        assert_allclose(m, 808.7925, atol=1e-6)
-        assert_allclose(np.mean(f2["/data/param_sweep_0"]), m, atol=1e-6)
-
-    with h5py.File(serial_normalize_tomo, "r") as f, h5py.File(
-        parallel_normalize_tomo, "r"
-    ) as f2:
-        assert (
-            f["/data/param_sweep_1"].shape
-            == (180, 128, 160)
-            == f2["/data/param_sweep_1"].shape
-        )
-        assert (
-            f["/data/param_sweep_1"].dtype
-            == np.float32
-            == f2["/data/param_sweep_1"].dtype
-        )
-
-        s = np.sum(f["/data/param_sweep_1"])
-        assert_allclose(s, 3053065.5, atol=1e-6)
-        assert_allclose(np.sum(f2["/data/param_sweep_1"]), s, atol=1e-6)
-
-        m = np.mean(f["/data/param_sweep_1"])
-        assert_allclose(m, 0.828197, atol=1e-6)
-        assert_allclose(np.mean(f2["/data/param_sweep_1"]), m, atol=1e-6)
-
-    serial_log_files = list(filter(lambda x: ".log" in x, serial_files))
-    parallel_log_files = list(filter(lambda x: ".log" in x, parallel_files))
-    assert len(serial_log_files) == 1
-    assert len(parallel_log_files) == 1
-
-    log_contents = _get_log_contents(serial_log_files[0])
-    mpi_log_contents = _get_log_contents(parallel_log_files[0])
-
-    assert "DEBUG | The full dataset shape is (220, 128, 160)" in log_contents
-    assert (
-        "DEBUG | RANK: [0], Data shape is (180, 128, 160) of type uint16"
-        in log_contents
-    )
-    assert "DEBUG | Preview: (0:180, :, :)" in log_contents
-    assert "INFO | ~~~ Pipeline finished ~~~" in log_contents
 
-    #: user log and mpi log would differ in the data shapes
-    assert (
-        "DEBUG | RANK: [0], Data shape is (45, 128, 160) of type uint16"
-        in mpi_log_contents
-    )
-"""
+def test_run_pipeline_360deg_gpu2(cmd, data360, yaml_gpu_pipeline360_2, output_folder):
+    cmd.pop(4)  #: don't save all
+    cmd.insert(6, data360)
+    cmd.insert(7, yaml_gpu_pipeline360_2)
+    cmd.insert(8, output_folder)
+    subprocess.check_output(cmd)
 
-@pytest.mark.preview
-def test_sweep_range_pipeline_with_step_absent(
-    cmd, standard_data, sample_pipelines, output_folder
-):
-    cmd.insert(7, standard_data)
-    cmd.insert(8, sample_pipelines + "testing/step_absent.yml")
-    subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
+    # recurse through output_dir and check that all files are there
+    files = read_folder("output_dir/")
+    assert len(files) == 6
+
+    #: 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, read_folder("output_dir/")))
+    log_files = list(filter(lambda x: ".log" in x, files))
     assert len(log_files) == 1
 
     log_contents = _get_log_contents(log_files[0])
 
-    assert (
-        "ERROR | Please provide `start`, `stop`, `step` values"
-        " when specifying a range to peform a parameter sweep over."
-    ) in log_contents
+    assert "The full dataset shape is (3751, 3, 2560)" in log_contents
+    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
diff --git a/tests/test_task_runner.py b/tests/test_task_runner.py
deleted file mode 100644
index 16c3d3c02..000000000
--- a/tests/test_task_runner.py
+++ /dev/null
@@ -1,157 +0,0 @@
-from unittest import mock
-import pytest
-import numpy as np
-
-from httomo.task_runner import (
-    MethodFunc,
-    PlatformSection,
-    _update_max_slices,
-    _determine_platform_sections,
-)
-from httomo.utils import Pattern
-
-
-def _dummy():
-    pass
-
-
-def make_test_method(
-    gpu=False,
-    pattern=Pattern.projection,
-    module_name="testmodule",
-    wrapper_function=None,
-    calc_max_slices=None,
-):
-    return MethodFunc(
-        cpu=not gpu,
-        gpu=gpu,
-        module_name=module_name,
-        pattern=pattern,
-        method_func=_dummy,
-        wrapper_func=wrapper_function,
-        calc_max_slices=calc_max_slices,
-    )
-
-
-def test_determine_platform_sections_single() -> None:
-    methods = [make_test_method()]
-    sections = _determine_platform_sections(methods, save_all=False)
-
-    assert len(sections) == 1
-    s0 = sections[0]
-    assert s0.gpu is False
-    assert s0.methods == methods
-    assert s0.pattern == methods[0].pattern
-
-
-def test_determine_platform_sections_two_cpu() -> None:
-    methods = [
-        make_test_method(),
-        make_test_method(),
-    ]
-    sections = _determine_platform_sections(methods, save_all=False)
-
-    assert len(sections) == 1
-    s0 = sections[0]
-    assert s0.gpu is False
-    assert s0.methods == methods
-    assert s0.pattern == methods[0].pattern
-
-
-def test_determine_platform_sections_pattern_change() -> None:
-    methods = [
-        make_test_method(pattern=Pattern.projection),
-        make_test_method(pattern=Pattern.sinogram),
-    ]
-    sections = _determine_platform_sections(methods, save_all=False)
-
-    assert len(sections) == 2
-    s0 = sections[0]
-    assert s0.gpu is False
-    assert s0.methods == [methods[0]]
-    assert s0.pattern == methods[0].pattern
-    s1 = sections[1]
-    assert s1.gpu is False
-    assert s1.methods == [methods[1]]
-    assert s1.pattern == methods[1].pattern
-
-
-def test_determine_platform_sections_platform_change() -> None:
-    methods = [
-        make_test_method(gpu=False),
-        make_test_method(gpu=True),
-    ]
-    sections = _determine_platform_sections(methods, save_all=False)
-
-    assert len(sections) == 2
-    s0 = sections[0]
-    assert s0.gpu is False
-    assert s0.methods == [methods[0]]
-    assert s0.pattern == methods[0].pattern
-    s1 = sections[1]
-    assert s1.gpu is True
-    assert s1.methods == [methods[1]]
-    assert s1.pattern == methods[1].pattern
-
-
-@pytest.mark.parametrize(
-    "pattern1, pattern2, expected",
-    [
-        (Pattern.projection, Pattern.all, Pattern.projection),
-        (Pattern.all, Pattern.projection, Pattern.projection),
-        (Pattern.sinogram, Pattern.all, Pattern.sinogram),
-        (Pattern.all, Pattern.sinogram, Pattern.sinogram),
-        (Pattern.all, Pattern.all, Pattern.all),
-    ],
-    ids=[
-        "proj-all-proj",
-        "all-proj-proj",
-        "sino-all-sino",
-        "all-sino-sino",
-        "all-all-all",
-    ],
-)
-def test_determine_platform_sections_pattern_all_combine(
-    pattern1: Pattern, pattern2: Pattern, expected: Pattern
-) -> None:
-    methods = [
-        make_test_method(pattern=pattern1),
-        make_test_method(pattern=pattern2),
-    ]
-    sections = _determine_platform_sections(methods, save_all=False)
-
-    assert len(sections) == 1
-    s0 = sections[0]
-    assert s0.gpu is False
-    assert s0.methods == methods
-    assert s0.pattern == expected
-
-
-def test_platform_section_max_slices():
-    data_shape = (1000, 24, 42)
-    dict_datasets_pipeline={}
-    dict_datasets_pipeline['tomo'] = np.float32(np.zeros(data_shape))
-    section = PlatformSection(
-        gpu=True,
-        pattern=Pattern.projection,        
-        reslice=False,
-        max_slices=0,
-        methods=[
-            make_test_method(
-                pattern=Pattern.projection, gpu=True, calc_max_slices=[{'datasets': ['tomo']}, {'multipliers': [1]}, {'methods': ['direct']}]
-            ),
-            make_test_method(
-                pattern=Pattern.projection, gpu=True, calc_max_slices=[{'datasets': ['tomo']}, {'multipliers': [1]}, {'methods': ['direct']}]
-            ),
-            make_test_method(
-                pattern=Pattern.projection, gpu=True, calc_max_slices=[{'datasets': ['tomo']}, {'multipliers': [1]}, {'methods': ['direct']}]
-            ),
-        ],
-    )
-    with mock.patch(
-        "httomo.task_runner._get_available_gpu_memory", return_value=100000
-    ):
-        output_dims,dtype = _update_max_slices(section, 0, data_shape, np.float32(), dict_datasets_pipeline)
-
-    assert output_dims == data_shape
-    assert dtype == np.float32()
\ No newline at end of file
diff --git a/tests/test_wrappers.py b/tests/test_wrappers.py
deleted file mode 100644
index 321075769..000000000
--- a/tests/test_wrappers.py
+++ /dev/null
@@ -1,48 +0,0 @@
-import dataclasses
-import inspect
-from unittest import mock
-from mpi4py import MPI
-import numpy as np
-
-from httomo.wrappers_class import BackendWrapper
-
-def test_tomopy_wrapper():
-    wrp = BackendWrapper("tomopy", "recon", "algorithm", "recon", MPI.COMM_WORLD)
-    assert inspect.ismodule(wrp.module)
-
-def test_httomolib_wrapper():
-    wrp = BackendWrapper("httomolib", "misc", "images", "save_to_images", MPI.COMM_WORLD)
-    assert inspect.ismodule(wrp.module)
-    
-def test_httomolibgpu_wrapper():
-    wrp = BackendWrapper("httomolibgpu", "prep", "normalize", "normalize", MPI.COMM_WORLD)
-    assert inspect.ismodule(wrp.module)
-
-# def test_httomolibgpu_wrapper_max_slices_gpu():
-#     wrp = HttomolibgpuWrapper("prep", "normalize", "normalize", MPI.COMM_WORLD)
-#     assert wrp.cupyrun is True
-#     assert wrp.calc_max_slices(0, (100, 100), np.uint8(), 50000)[0] < 100000
-
-
-# def test_httomolibgpu_wrapper_max_slices_passes_kwargs():
-#     from httomolibgpu.prep.normalize import normalize
-
-#     mock_method = mock.Mock()
-#     mockMeta = dataclasses.replace(normalize.meta, calc_max_slices=mock_method)
-#     with mock.patch.object(normalize, "meta", mockMeta):
-#         wrp = HttomolibgpuWrapper("prep", "normalize", "normalize", MPI.COMM_WORLD)
-#         wrp.dict_params = dict(testarg=1, minus_log=True)
-#         wrp.calc_max_slices(0, (100, 100), np.uint8(), 50000)
-
-#     # make sure the default args are called and the args given above are overriding the defaults
-#     mock_method.assert_called_once_with(
-#         0,
-#         (100, 100),
-#         np.uint8(),
-#         50000,
-#         cutoff=10.0,
-#         minus_log=True,
-#         testarg=1,
-#         nonnegativity=False,
-#         remove_nans=False,
-#     )
diff --git a/tests/test_yaml_checker.py b/tests/test_yaml_checker.py
index 97559a812..a840841ad 100644
--- a/tests/test_yaml_checker.py
+++ b/tests/test_yaml_checker.py
@@ -7,7 +7,6 @@
 from httomo.yaml_checker import (
     check_all_stages_defined,
     check_all_stages_non_empty,
-    check_first_stage_has_loader,
     check_hdf5_paths_against_loader,
     check_loading_stage_one_method,
     check_methods_exist_in_templates,
@@ -16,9 +15,10 @@
     sanity_check,
     validate_yaml_config,
 )
-from httomo.yaml_loader import YamlLoader
 
 
+# TODO: yaml checker needs to be modified first 
+"""
 def test_sanity_check(sample_pipelines, yaml_loader: type[YamlLoader]):
     wrong_indentation_pipeline = (
         sample_pipelines + "testing/wrong_indentation_pipeline.yaml"
@@ -58,15 +58,6 @@ def test_invalid_loader_stage(sample_pipelines, yaml_loader: type[YamlLoader]):
     assert not check_loading_stage_one_method(conf)
 
 
-def test_first_stage_has_loader(sample_pipelines, yaml_loader: type[YamlLoader]):
-    incorrect_first_stage_pipeline = (
-        sample_pipelines + "testing/incorrect_first_stage.yaml"
-    )
-    with open(incorrect_first_stage_pipeline, "r") as f:
-        conf = list(yaml.load_all(f, Loader=yaml_loader))
-    assert not check_first_stage_has_loader(conf)
-
-
 def test_one_method_per_module(more_than_one_method, yaml_loader: type[YamlLoader]):
     with open(more_than_one_method, "r") as f:
         conf = list(yaml.load_all(f, Loader=yaml_loader))
@@ -132,3 +123,5 @@ def test_validate_yaml_config(
 ):
     yaml_file = sample_pipelines + yaml_file
     assert validate_yaml_config(yaml_file, yaml_loader, standard_data) == expected
+
+"""
diff --git a/tests/ui/__init__.py b/tests/ui/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/ui/test_ui_layer_py.py b/tests/ui/test_ui_layer_py.py
new file mode 100644
index 000000000..3e4751791
--- /dev/null
+++ b/tests/ui/test_ui_layer_py.py
@@ -0,0 +1,166 @@
+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
new file mode 100644
index 000000000..d2e23a230
--- /dev/null
+++ b/tests/ui/test_ui_layer_yaml.py
@@ -0,0 +1,164 @@
+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'