diff --git a/src/calng/AgipdCorrection.py b/src/calng/AgipdCorrection.py
index c763ebbb5511304cd29f60f772db9233e6f5e1b6..3b894b80b2ebcc5c09c1b359e7b3ff7deb4db7f4 100644
--- a/src/calng/AgipdCorrection.py
+++ b/src/calng/AgipdCorrection.py
@@ -14,7 +14,7 @@ from karabo.bound import (
 )
 from karabo.common.states import State
 
-from . import shmem_utils
+from . import shmem_utils, utils
 from ._version import version as deviceVersion
 from .agipd_gpu import AgipdGainMode, AgipdGpuRunner, BadPixelValues, CorrectionFlags
 from .base_correction import BaseCorrection, add_correction_step_schema
@@ -35,7 +35,10 @@ class AgipdCorrection(BaseCorrection):
     _gpu_runner_class = AgipdGpuRunner
     _calcat_friend_class = AgipdCalcatFriend
     _constant_enum_class = AgipdConstants
-    _managed_keys = BaseCorrection._managed_keys[:] + ["overrideInputAxisOrder"]
+    _managed_keys = BaseCorrection._managed_keys | {
+        "overrideInputAxisOrder",
+        "sendGainMap",
+    }
 
     # this is just extending (not mandatory)
     _schema_cache_fields = BaseCorrection._schema_cache_fields | {
@@ -69,7 +72,6 @@ class AgipdCorrection(BaseCorrection):
             .defaultValue(False)
             .commit(),
         )
-        AgipdCorrection._managed_keys.append("sendGainMap")
         # TODO: make sendGainMap reconfigurable
 
         (
@@ -166,12 +168,12 @@ class AgipdCorrection(BaseCorrection):
             )
             .commit(),
         )
-        AgipdCorrection._managed_keys.append(
+        AgipdCorrection._managed_keys.add(
             "corrections.relGainPc.overrideMdAdditionalOffset"
         )
-        AgipdCorrection._managed_keys.append("corrections.relGainPc.mdAdditionalOffset")
-        AgipdCorrection._managed_keys.append("corrections.relGainXray.gGainValue")
-        AgipdCorrection._managed_keys.append("corrections.badPixels.maskingValue")
+        AgipdCorrection._managed_keys.add("corrections.relGainPc.mdAdditionalOffset")
+        AgipdCorrection._managed_keys.add("corrections.relGainXray.gGainValue")
+        AgipdCorrection._managed_keys.add("corrections.badPixels.maskingValue")
         # TODO: DRY / encapsulate
         for field in BadPixelValues:
             (
@@ -182,7 +184,7 @@ class AgipdCorrection(BaseCorrection):
                 .reconfigurable()
                 .commit()
             )
-            AgipdCorrection._managed_keys.append(
+            AgipdCorrection._managed_keys.add(
                 f"corrections.badPixels.subsetToUse.{field.name}"
             )
 
@@ -191,7 +193,7 @@ class AgipdCorrection(BaseCorrection):
             VECTOR_STRING_ELEMENT(expected)
             .key("managedKeys")
             .assignmentOptional()
-            .defaultValue(AgipdCorrection._managed_keys)
+            .defaultValue(list(AgipdCorrection._managed_keys))
             .commit()
         )
 
@@ -312,20 +314,17 @@ class AgipdCorrection(BaseCorrection):
             if do_generate_preview:
                 if self._correction_flag_enabled != self._correction_flag_preview:
                     self.gpu_runner.correct(self._correction_flag_preview)
-                # WARNING: actually looking for cell ID at the request of SPB
-                preview_slice_index = self._schema_cache["preview.pulse"]
-                if preview_slice_index >= 0:
-                    # look at cell_table to find which index this pulse ID is in
-                    cell_id_found = np.where(cell_table == preview_slice_index)[0]
-                    if len(cell_id_found) == 0:
-                        cell_found_instead = cell_table[0]
-                        self.log_status_info(
-                            f"Cell {preview_slice_index} not found, arbitrary cell "
-                            f"{cell_found_instead} will be shown instead."
-                        )
-                        preview_slice_index = 0
-                    else:
-                        preview_slice_index = cell_id_found[0]
+                (
+                    preview_slice_index,
+                    preview_cell,
+                    preview_pulse,
+                ) = utils.pick_frame_index(
+                    self._schema_cache["preview.selectionMode"],
+                    self._schema_cache["preview.index"],
+                    cell_table,
+                    pulse_table,
+                    warn_func=self.log_status_warn,
+                )
                 preview_raw, preview_corrected = self.gpu_runner.compute_preview(
                     preview_slice_index
                 )
diff --git a/src/calng/DsscCorrection.py b/src/calng/DsscCorrection.py
index 7bd0b665becdfb3eda6e12391810b3162818e6a2..e5fdc6ee362c220ffea95dbe5317cb0406aff245 100644
--- a/src/calng/DsscCorrection.py
+++ b/src/calng/DsscCorrection.py
@@ -4,6 +4,7 @@ import numpy as np
 from karabo.bound import KARABO_CLASSINFO, VECTOR_STRING_ELEMENT
 from karabo.common.states import State
 
+from . import utils
 from ._version import version as deviceVersion
 from .base_correction import BaseCorrection, add_correction_step_schema
 from .calcat_utils import DsscCalcatFriend, DsscConstants
@@ -18,7 +19,7 @@ class DsscCorrection(BaseCorrection):
     _gpu_runner_class = DsscGpuRunner
     _calcat_friend_class = DsscCalcatFriend
     _constant_enum_class = DsscConstants
-    _managed_keys = BaseCorrection._managed_keys[:]
+    _managed_keys = BaseCorrection._managed_keys.copy()
 
     @staticmethod
     def expectedParameters(expected):
@@ -34,7 +35,7 @@ class DsscCorrection(BaseCorrection):
             VECTOR_STRING_ELEMENT(expected)
             .key("managedKeys")
             .assignmentOptional()
-            .defaultValue(DsscCorrection._managed_keys)
+            .defaultValue(list(DsscCorrection._managed_keys))
             .commit()
         )
 
@@ -118,22 +119,17 @@ class DsscCorrection(BaseCorrection):
             if do_generate_preview:
                 if self._correction_flag_enabled != self._correction_flag_preview:
                     self.gpu_runner.correct(self._correction_flag_preview)
-                preview_slice_index = self._schema_cache["preview.pulse"]
-                if preview_slice_index >= 0:
-                    # look at pulse_table to find which index this pulse ID is in
-                    pulse_id_found = np.where(pulse_table == preview_slice_index)[0]
-                    if len(pulse_id_found) == 0:
-                        pulse_found_instead = pulse_table[0]
-                        msg = (
-                            f"Pulse {preview_slice_index} not found in "
-                            f"image.pulseId, arbitrary pulse "
-                            f"{pulse_found_instead} will be shown."
-                        )
-                        self.log.WARN(msg)
-                        self.set("status", msg)
-                        preview_slice_index = 0
-                    else:
-                        preview_slice_index = pulse_id_found[0]
+                (
+                    preview_slice_index,
+                    preview_cell,
+                    preview_pulse,
+                ) = utils.pick_frame_index(
+                    self._schema_cache["preview.selectionMode"],
+                    self._schema_cache["preview.index"],
+                    cell_table,
+                    pulse_table,
+                    warn_func=self.log_status_warn,
+                )
                 preview_raw, preview_corrected = self.gpu_runner.compute_preview(
                     preview_slice_index,
                 )
diff --git a/src/calng/base_correction.py b/src/calng/base_correction.py
index 271736c04fa92af3cc0eeaa1d23804b39576479b..5aa158c24e7c21d29f4af99b19010056eec664d1 100644
--- a/src/calng/base_correction.py
+++ b/src/calng/base_correction.py
@@ -46,15 +46,16 @@ class BaseCorrection(PythonDevice):
     _correction_flag_class = None  # subclass must set to some enum class
     _gpu_runner_class = None  # subclass must set this
     _gpu_runner_init_args = {}  # subclass can set this (TODO: remove, design better)
-    _managed_keys = [
+    _managed_keys = {
         "outputShmemBufferSize",
         "dataFormat.outputAxisOrder",
         "dataFormat.outputImageDtype",
         "preview.enable",
-        "preview.pulse",
+        "preview.index",
+        "preview.selectionMode",
         "preview.trainIdModulo",
         "loadMostRecentConstants",
-    ]  # subclass must extend this and put it in schema
+    }  # subclass must extend this and put it in schema
     _schema_cache_fields = {
         "doAnything",
         "constantParameters.memoryCells",
@@ -63,7 +64,8 @@ class BaseCorrection(PythonDevice):
         "dataFormat.pixelsY",
         "dataFormat.outputAxisOrder",
         "preview.enable",
-        "preview.pulse",
+        "preview.index",
+        "preview.selectionMode",
         "preview.trainIdModulo",
         "processingStateTimeout",
         "state",
@@ -318,8 +320,14 @@ class BaseCorrection(PythonDevice):
 
         preview_schema = Schema()
         (
-            NODE_ELEMENT(preview_schema).key("data").commit(),
-            NDARRAY_ELEMENT(preview_schema).key("data.adc").dtype("FLOAT").commit(),
+            NODE_ELEMENT(preview_schema).key("image").commit(),
+            NDARRAY_ELEMENT(preview_schema).key("image.data").dtype("FLOAT").commit(),
+            UINT64_ELEMENT(preview_schema)
+            .key("image.trainId")
+            .displayedName("Train ID")
+            .assignmentOptional()
+            .defaultValue(0)
+            .commit(),
         )
         (
             NODE_ELEMENT(expected).key("preview").displayedName("Preview").commit(),
@@ -340,23 +348,32 @@ class BaseCorrection(PythonDevice):
             .commit(),
             # TODO: Split into AGIPD-specific or see if others like cell ID over pulse ID
             INT32_ELEMENT(expected)
-            .key("preview.pulse")
-            .displayedName("Cell (or stat) for preview")
+            .key("preview.index")
+            .displayedName("Index (or stat) for preview")
             .description(
-                "If this value is ≥ 0, the corresponding index from data will be "
-                "sliced for the preview. If this value is ≤ 0, preview will be one of "
-                "the following stats:"
-                "-1: max, "
-                "-2: mean, "
-                "-3: sum, "
-                "-4: stdev. "
-                "Max means selecting the pulse with the maximum integrated value. The "
-                "others are computed across all filtered pulses in the train."
+                "If this value is ≥ 0, the corresponding index (frame, cell, or pulse) "
+                "will be sliced for the preview output. If this value is ≤ 0, preview "
+                "will be one of the following stats: -1: max, -2: mean, -3: sum, -4: "
+                "stdev. These stats are computed across memory cells."
             )
             .assignmentOptional()
             .defaultValue(0)
             .reconfigurable()
             .commit(),
+            STRING_ELEMENT(expected)
+            .key("preview.selectionMode")
+            .displayedName("Index selection mode")
+            .description(
+                "The value of preview.index can be used in multiple ways, controlled "
+                "by this value. If this is set to 'frame', preview.index is sliced "
+                "directly from data. If 'cell' (or 'pulse') is selected, I will look at "
+                "cell (or pulse) table for the requested cell (or pulse ID)."
+            )
+            .options("frame,cell,pulse")
+            .assignmentOptional()
+            .defaultValue("frame")
+            .reconfigurable()
+            .commit(),
             UINT32_ELEMENT(expected)
             .key("preview.trainIdModulo")
             .displayedName("Train modulo for throttling")
@@ -604,7 +621,6 @@ class BaseCorrection(PythonDevice):
         # TODO: allow sending *all* frames for commissioning (request: Jola)
         preview_hash = Hash()
         preview_hash.set("image.trainId", train_id)
-        preview_hash.set("image.cellId", self._schema_cache["preview.pulse"])
 
         # note: have to construct because setting .tid after init is broken
         timestamp = Timestamp(Epochstamp(), Trainstamp(train_id))
@@ -736,5 +752,5 @@ def add_correction_step_schema(schema, managed_keys, field_flag_mapping):
             .reconfigurable()
             .commit(),
         )
-        managed_keys.append(f"{node_name}.enable")
-        managed_keys.append(f"{node_name}.preview")
+        managed_keys.add(f"{node_name}.enable")
+        managed_keys.add(f"{node_name}.preview")
diff --git a/src/calng/calcat_utils.py b/src/calng/calcat_utils.py
index 9d394f047a435040f8669ca89d3eb14f8b5bb0f2..032dcad4fc4ccedb801a0a840b140d117f9c2fb0 100644
--- a/src/calng/calcat_utils.py
+++ b/src/calng/calcat_utils.py
@@ -253,12 +253,12 @@ class BaseCalcatFriend:
             .reconfigurable()
             .commit(),
         )
-        managed_keys.append(f"{param_prefix}.deviceMappingSnapshotAt")
-        managed_keys.append(f"{param_prefix}.constantVersionEventAt")
-        managed_keys.append(f"{param_prefix}.memoryCells")
-        managed_keys.append(f"{param_prefix}.pixelsX")
-        managed_keys.append(f"{param_prefix}.pixelsY")
-        managed_keys.append(f"{param_prefix}.biasVoltage")
+        managed_keys.add(f"{param_prefix}.deviceMappingSnapshotAt")
+        managed_keys.add(f"{param_prefix}.constantVersionEventAt")
+        managed_keys.add(f"{param_prefix}.memoryCells")
+        managed_keys.add(f"{param_prefix}.pixelsX")
+        managed_keys.add(f"{param_prefix}.pixelsY")
+        managed_keys.add(f"{param_prefix}.biasVoltage")
 
     def __init__(
         self,
@@ -599,9 +599,9 @@ class AgipdCalcatFriend(BaseCalcatFriend):
             .reconfigurable()
             .commit(),
         )
-        managed_keys.append(f"{param_prefix}.acquisitionRate")
-        managed_keys.append(f"{param_prefix}.gainSetting")
-        managed_keys.append(f"{param_prefix}.photonEnergy")
+        managed_keys.add(f"{param_prefix}.acquisitionRate")
+        managed_keys.add(f"{param_prefix}.gainSetting")
+        managed_keys.add(f"{param_prefix}.photonEnergy")
 
         _add_status_schema_from_enum(schema, status_prefix, AgipdConstants)
 
diff --git a/src/calng/utils.py b/src/calng/utils.py
index 2e9b25467fb737131b9a003d475d06d12bfd20c1..0bd0a25a6c5af8273812dd9f6bbe2ae7a988fd62 100644
--- a/src/calng/utils.py
+++ b/src/calng/utils.py
@@ -8,6 +8,64 @@ import timeit
 import numpy as np
 
 
+def pick_frame_index(selection_mode, index, cell_table, pulse_table, warn_func=None):
+    """When selecting a single frame to preview, an obvious question is whether the
+    number the operator provides is a frame index, a cell ID, or a pulse ID. This
+    function allows any of the three, translating into frame index.
+
+    As this will be used by correction devices, the warn_func parameter allows the
+    function to issue warnings via this instead of raising exceptions.
+
+    Indices below zero are special values and thus returned directly.
+
+    Returns: (found frame index, corresponding cell ID, corresponding pulse ID)"""
+
+    if index < 0:
+        return index, index, index
+
+    # TODO: enum
+    if selection_mode == "frame":
+        if index >= len(cell_table):
+            if warn_func is not None:
+                warn_func(
+                    f"Index {index} out of range for cell table of length "
+                    f"{len(cell_table)}, returning index 0 instead"
+                )
+            frame_index = 0
+        else:
+            frame_index = index
+
+        return frame_index, cell_table[frame_index], pulse_table[frame_index]
+    elif selection_mode == "cell":
+        found = np.where(cell_table == index)[0]
+        if len(found) > 0:
+            cell = index
+            frame_index = found[0]
+        else:
+            cell = cell_table[0]
+            if warn_func is not None:
+                warn_func(
+                    f"Cell {index} not found, arbitrary cell {cell} returned instead"
+                )
+            frame_index = 0
+        return frame_index, cell, pulse_table[frame_index]
+    elif selection_mode == "pulse":
+        found = np.where(pulse_table == index)[0]
+        if len(found) > 0:
+            pulse = index
+            frame_index = found[0]
+        else:
+            pulse = pulse_table[0]
+            if warn_func is not None:
+                warn_func(
+                    f"Pulse {index} not found, arbitrary pulse {pulse} returned instead"
+                )
+            frame_index = 0
+        return frame_index, cell_table[frame_index], pulse
+    else:
+        raise ValueError(f"Invalid selection mode '{selection_mode}'")
+
+
 def threadsafe_cache(fun):
     """This decorator imitates functools.cache, but threadsafer