Instrument View#

Overview#

The Instrument View draws the geometry of an instrument, coloured by the data recorded in the corresponding workspace, and lets the user select detectors, plot their spectra, overlay peaks and build masks, regions of interest and detector groupings.

It is a pure-Python package built on PyVista and VTK, and is completely separate from the OpenGL widget described in Legacy Instrument Viewer Widget. The two ship side by side: since v7.0.0 Show Instrument opens this one, and Show Instrument (Legacy) opens the legacy widget.

The user documentation is at Instrument Viewer Widget.

Where the code lives#

The package is qt/python/instrumentview, and is registered from qt/python/CMakeLists.txt with:

add_python_package(instrumentview)
add_dependencies(instrumentview mantidqt)

Workbench depends on it, through install_requires in qt/applications/workbench/setup.py and add_dependencies(workbench ... instrumentview). There is no separate conda recipe; the package is installed as part of the mantidworkbench build, and its runtime dependencies pyvista, pyvistaqt and superqt are pinned in conda/recipes/conda_build_config.yaml.

Module

Responsibility

FullInstrumentViewWindow.py

FullInstrumentViewWindow (the QMainWindow shell) and FullInstrumentViewView (all of the widgets)

FullInstrumentViewPresenter.py

Event handling, renderer and interactor selection

FullInstrumentViewModel.py

All workspace-side logic; no Qt

renderers/

Building the detector meshes and the picking callbacks

Projections/

2D projections of the detector positions

InteractorStyles.py

VTK mouse interaction styles

ShapeWidgets.py, ShapeOverlayManager.py

The ROI and mask selection shapes, and the chart overlay they are drawn on

ComponentTreeModel.py, ComponentTreeView.py, ComponentTreePresenter.py

The instrument component tree

Peaks/

Reading and grouping peaks from a PeaksWorkspace

InstrumentViewADSObserver.py

Reacting to workspaces being added, renamed or deleted

NotebookView.py, NotebookPresenter.py, NotebookUtils.py

A cut-down view for Jupyter notebooks

InstrumentView.py, __main__.py

Standalone and command line entry points

alfview/, isisreflectometry/

Specialisations embedded in other interfaces

Architecture#

The interface is Model/View/Presenter, as described in MVP Design:

FullInstrumentViewWindow (QMainWindow)
  └── FullInstrumentViewView ──────────── FullInstrumentViewPresenter ──── FullInstrumentViewModel
         │  widgets, plotter, line plot      on_* handlers, threading         workspace, detectors,
         │                                                                    masks, peaks
         ├── ShapeOverlayManager                     │
         │      └── SelectionShape                   ├── InstrumentRenderer
         ├── ComponentTreeView                       │      ├── PointCloudRenderer
         │      └── ComponentTreePresenter           │      ├── ShapeRenderer
         │             └── ComponentTreeModel        │      └── SideBySideShapeRenderer
         └── BackgroundPlotter (pyvistaqt)           ├── InteractorStyles
                                                     ├── Projection
                                                     └── InstrumentViewADSObserver

Note that FullInstrumentViewWindow.py contains two classes. FullInstrumentViewWindow is a thin QMainWindow that sizes and centres itself and holds FullInstrumentViewView as its central widget; all of the interesting view code is in the latter.

The three parts are assembled by the caller rather than by a factory, identically in InstrumentView.py and in Workbench (qt/applications/workbench/workbench/plugins/workspacewidget.py):

model = FullInstrumentViewModel(ws)
window = FullInstrumentViewWindow()
FullInstrumentViewPresenter(window.get_instrument_view_widget(), model)

Connecting the view to the presenter#

There are no custom Qt signals. Wiring happens in two steps from FullInstrumentViewPresenter.setup():

  1. view.subscribe_presenter(self) stores the presenter on the view, which then pulls the contents of its combo boxes from it (available_unit_options(), count_scale_combo_options(), peaks_workspaces_in_ads()).

  2. view.setup_connections_to_presenter() connects the stock Qt widget signals directly to presenter methods:

    self._projection_combo_box.currentIndexChanged.connect(self._presenter.on_projection_option_changed)
    self._mask_list.itemChanged.connect(partial(self._presenter.on_list_item_selected, CurrentTab.Masking))
    

The presenter never touches a widget directly; it calls named methods on the view such as set_contour_range_limits or show_plot_for_detectors. The model imports no Qt at all.

Threading#

This is the part most likely to catch out a new developer. There is no QThread and no progress bar. Responsiveness comes from three mechanisms working together.

A worker thread in the presenter. The presenter owns a daemon thread and a Queue. Almost every on_* handler does nothing but enqueue its _on_* counterpart, so the Qt thread returns immediately:

self._callback_queue = Queue()
self._callback_thread = Thread(None, self._callback_worker, daemon=True)
self._callback_thread.start()

handle_close() pushes a sentinel object to stop the worker.

Automatic marshalling back onto the Qt thread. FullInstrumentViewView is decorated with @run_on_qapp_thread() from mantidqt.utils.qt.qappthreadcall, which replaces every public method with a blocking QAppThreadCall. The worker thread can therefore call view methods directly. Note that private, underscore-prefixed methods are not wrapped, so a public method that delegates to a private one is safe, but calling a private method from the worker thread is not.

VTK work must happen on the Qt thread first. Anything that touches the VTK render window has to run before the work is queued. on_shape_changed is the pattern to copy:

def on_shape_changed(self):
    centres = self._model.transformed_detector_positions
    self._view.project_and_cache_detector_points(centres)   # VTK, so Qt thread
    self._shape_update_generation += 1
    self._callback_queue.put((self._on_shape_changed, (centres, self._shape_update_generation)))

The generation counter lets the worker discard superseded updates, which matters when a shape is dragged or the wheel is scrolled and events arrive faster than they can be processed.

The queue also means the state a handler read can have moved on by the time the work runs, so a handler that acts on the current selection has to snapshot it rather than read it again later. on_create_item_from_selection_clicked is the pattern to copy:

def on_create_item_from_selection_clicked(self):
    selection = self._model.picked_detector_mask
    pickable = self._model.is_pickable
    point_picks = self._model.point_picked_detectors
    tab = self._view.get_current_selected_tab()
    self._callback_queue.put((self._on_create_item_from_selection_clicked, (selection, pickable, point_picks, tab)))

The is_pickable mask is carried along because selection is positioned by it, so the worker can compare and refuse to commit if masking or a component tree selection has swapped detectors in or out of the pickable set meanwhile. The count would be unchanged, but every entry after the swap would describe a different detector.

Related helpers are the SuppressRendering context manager, used to batch plotter updates, and the _skip_if_closing decorator, which guards view methods against calls arriving after closeEvent.

Rendering#

The plotter is a pyvistaqt.BackgroundPlotter whose app_window is placed in a splitter alongside a matplotlib canvas for the line plot:

self.main_plotter = BackgroundPlotter(show=False, menu_bar=False, toolbar=False, off_screen=self._off_screen)

renderers/base_renderer.py defines the InstrumentRenderer abstract base class. A renderer builds two meshes and supplies the picking callback:

  • the detector mesh, built from the pickable detectors and coloured by counts. It is added with pickable=True and is the only mesh clicks are picked against;

  • the masked mesh, drawn in a flat dark grey with pickable=False.

Monitors and the sample are not the renderer’s concern: the presenter adds them as separate point meshes through FullInstrumentViewView.add_rgba_mesh.

The selection is not a mesh of its own either. Each renderer draws it with a magenta marker on a persistent highlight actor, created once per plotter rebuild by create_picked_highlight_actor and afterwards only given new data and shown or hidden by update_picked_highlight. The marker is sized in screen pixels rather than by tinting the detectors, so it stays visible when a large instrument is zoomed out until each detector covers a pixel or less.

The three implementations correspond to the render modes offered in the GUI:

PointCloudRenderer

One point per detector in a single pv.PolyData, drawn as screen-space spheres. Picking uses a vtkPointPicker. The selection is a larger sphere sprite centred on each picked detector, with its middle discarded in a fragment shader so the counts colour still shows through the ring.

ShapeRenderer

Draws real detector geometry. Detectors are grouped by unique shape using componentInfo.shapeToComponentIndices(), so the geometry is only built once per distinct shape. For each shape it either extracts an optimised four-vertex quad from the ShapeInfo, or falls back to the full triangulated mesh from CSGObject.getMesh(). The instances are then scaled, rotated and translated with vectorised NumPy using detectorInfo.allRotations(), allScaleFactors() and allPositions(), and merged into a single pv.PolyData so that VTK issues one draw call for the whole instrument. A _cell_to_detector array maps VTK cell IDs back to detector indices, for picking, for writing the scalars and for picking out the cells of the selected detectors.

The selection is outlined with the silhouette of the picked cells, from a vtkPolyDataSilhouette left in the pipeline because the silhouette changes as the camera moves. Edges would be wrong for anything but a flat quad: a raw cuboid would draw as a wireframe box and a raw cylinder as two cap rings. The outline is also lifted towards the camera by the depth of the picked shapes, or it would be buried in the detectors packed around it. Past _MAX_OUTLINE_CELLS picked cells, recomputing the silhouette on every camera move gets too slow, so a second actor takes over with one marker point per picked detector.

SideBySideShapeRenderer

Subclasses ShapeRenderer. It uses nearest-neighbour distances per bank (scipy.spatial.cKDTree) to scale the shapes so that unrolled panels do not overlap, and applies bank rotations only to tube banks.

Switching render mode does not rebuild everything: the presenter keeps the renderer instances alive and only reloads them when the workspace itself changes.

Projections#

Projections/Projection.py is a base class with a self-registering subclass registry, so a new projection needs no changes to any factory or combo box:

class Projection:
    _registry = {}

    def __init_subclass__(cls, projection_types=None, **kwargs):
        for projection_type, defaults in projection_types.items():
            Projection._registry[projection_type] = (cls, defaults)

    def __new__(cls, type, **kwargs):
        if cls is Projection:
            subclass, _ = Projection._registry.get(type)
            return super().__new__(subclass)

The ProjectionType enum is used to derive the GUI’s list of projections. If u and v are the horizontal and vertical axes of the 2D projection, respectively, then the projections are created as follows.

  • SphericalProjection uses u = -atan2(y, x) and v = -acos(v/r).

  • CylindricalProjection is equal-area: u = -atan2(y, x) and v = z/|r|.

  • SideBySide finds the flat banks – grid banks through instrument.findGridDetectors() and tube banks through the shared C++ PanelsSurfaceCalculator – unrolls each into its own panel, and tiles the panels. Bank positions from the side-by-side-view-location tag in the IDF are honoured where present.

The base class also corrects the seam: it finds the largest gap in the u coordinate and shifts the range by multiples of u_period so that a bank is not split across the wrap-around point. project_points() is the public entry point, and is what ShapeRenderer uses to project the vertices of the detector shapes rather than just their centres.

Projections are cached on the model per (projection type, flip beam) pair. Fitting the projection to the window is not part of the projection: the presenter computes a scale matrix in _transform_mesh_to_fill_window() and assigns it to model.transform.

Picking and mouse interaction#

Each renderer supplies get_callback_tied_to_detector_index(plotter, callback, hover), which wraps a vtkPointPicker or vtkCellPicker and translates a hit into an index into the pickable detectors. Only the detector mesh has pickable=True, so masked detectors, monitors, the sample and the selection marker cannot be picked.

InteractorStyles.py holds five VTK styles, and FullInstrumentViewPresenter._update_interactor_style() chooses between them, in this order:

Condition

Style

Not a 2D projection

TRACKBALL

A shape overlay is active

SCROLL_ZOOM_NO_PICKING

Hover pick is checked

SCROLL_ZOOM_WITH_HOVER

Rectangle zoom is toggled on

RUBBERBAND_ZOOM

Otherwise

SCROLL_ZOOM_WITH_PICKING

CursorZoomInteractorStyle implements zooming about the cursor in parallel projection: it caches the world position under the cursor during mouse-move events and, on a wheel event, adjusts the camera’s parallel_scale and shifts the focal point so that point stays put. It caches a default camera state, resets to it on right-click, and snaps back to it when zoomed out past it. After each change it fires camera_changed_callback so that a screen-space shape overlay can re-evaluate which detectors it covers.

RubberBandZoomInteractorStyle replaces VTK’s default left-button observers so that holding Shift, Ctrl or Alt picks a detector instead of starting a rubber band.

Shape overlays#

ShapeWidgets.py defines a SelectionShape base class and the circle, rectangle, ellipse, annulus and hollow rectangle implementations. Shapes work in normalised [0, 1] viewport coordinates and provide outline_xy, fill_coords, hit_test, apply_resize_delta and indices_in_shape. hit_test returns "inside", "edge", "inner_edge" or "handle", which is what decides between moving, resizing and rotating, and which cursor is shown.

ShapeOverlayManager draws the shape on a transparent pv.Chart2D laid over the render, adds the VTK mouse observers, projects the 3D detector positions into chart coordinates, and returns the boolean mask of the detectors the shape covers.

The model#

FullInstrumentViewModel keeps full-length arrays for every detector and a set of boolean masks over them. Nearly every public property is a slice of one of those arrays:

self._is_valid = (self._is_monitor != "yes") & (self._workspace_indices != -1)
is_pickable = ~self._is_masked & self._is_valid & self._is_selected_in_tree

Understanding which mask a property is sliced by is usually enough to understand the property. Note in particular that indices coming back from a picking callback are indices into the pickable detectors, not into all detectors.

There are two masks describing the selection, and they differ in both length and content. picked_detector_mask has one entry per pickable detector, matching the meshes and the picking callbacks, and covers everything highlighted – both the detectors clicked in the projection and those of any ticked Grouping entry, which apply_detector_items unions together into _detector_is_picked. point_picked_detectors has one entry per detector, so it indexes the full-length arrays, and holds only the detectors clicked in the projection. It returns a copy, because the model picks into its own array in place and a caller holding it as a snapshot would otherwise see it change underneath them.

Bulk detector data is read with the CreateDetectorTable algorithm rather than a Python loop over detectors, which is much faster for large instruments:

detector_info_table = CreateDetectorTable(
    self._workspace, IncludeDetectorPosition=True, OneRowPerDetectorID=True,
    StoreInADS=False, EnableLogging=False
)

Integrated counts come from the C++ method MatrixWorkspace.getIntegratedCountsForWorkspaceIndices, again to avoid a per-spectrum loop. The line plot is produced with ExtractSpectra, then ConvertUnits if the plot units differ, then, when summing more than one spectrum, Rebin onto a common grid followed by SumSpectra. The rebin is necessary because ragged workspaces cannot be summed directly.

Component tree#

ComponentTreeModel wraps workspace.componentInfo(). ComponentTreePresenter builds a QStandardItemModel lazily: a node that has children is given a single child with the text ##placeholder##, which is replaced with the real children the first time the node is expanded. Without this, building the tree for a large instrument took several seconds.

Selection is propagated as the union of componentsInSubtree() for each selected node. The model converts those component indices to detector IDs and sets _is_selected_in_tree, so unselected detectors move into the masked mesh and become unpickable.

Reacting to the ADS#

InstrumentViewADSObserver subclasses AnalysisDataServiceObserver and wraps each of its five callbacks in a QAppThreadCall. The presenter then queues the real work onto its worker thread:

  • delete closes the window if it was the displayed workspace, otherwise refreshes the lists.

  • rename re-points the model at the renamed workspace and rebuilds the component tree.

  • replace re-renders, or refreshes the peaks, mask or grouping list depending on the type.

  • add adds new peaks, mask or grouping workspaces to the relevant list.

  • clear closes the window.

handle_close() deletes the observer explicitly rather than leaving it to the garbage collector, which would otherwise keep stale references to workspaces alive.

Extending the Instrument View#

New render mode

Implement InstrumentRenderer, add the mode string to FullInstrumentViewView._RENDER_MODE_OPTIONS and a branch to FullInstrumentViewPresenter._get_renderer_for_mode. Also override _add_picked_highlight_actor and _build_picked_highlight_mesh: they are not abstract, but the base versions draw nothing, and the marker is the only thing that shows the selection.

New projection

Add a member to ProjectionType and subclass Projection with projection_types={ProjectionType.MY_TYPE: {...}}. The combo box is populated from the enum, so nothing else needs changing.

New selection shape

Subclass SelectionShape and add it to FullInstrumentViewView._shape_options.

Reusing the view

Subclass FullInstrumentViewView and override _set_layouts to build a different layout from the same widgets, overriding any set_* methods that do not apply. This is what alfview does.

Reusing only the model

Compose FullInstrumentViewModel with a renderer and interactor styles directly, without FullInstrumentViewPresenter. This is what isisreflectometry does.

Warning

The values of Globals.CurrentTab are the literal tab label strings, and get_current_selected_tab() converts the label back with CurrentTab(tab_name). Renaming a tab in the GUI without changing the enum will break grouping and masking silently.

Interfaces that embed the Instrument View#

Both current consumers are C++ interfaces that import the Python presenter, and both use it unless the QSettings flag InstrumentView/use_legacy_instrument_view is set, exposed in Workbench under Settings -> General. Mind the sense of that flag: it names the widget it selects, so its default of false gets this one.

alfview/

ALFInstrumentViewPresenter and ALFInstrumentViewView subclass the full presenter and view. The view replaces the whole layout with a rebin box, a hover-pick button and a rectangle tool, and the presenter forces bank/tube selection, the Cylindrical Y projection and the approximated-shapes render mode. It calls back into C++ by finding a child QObject named ALFPythonCallbackRelay and invoking its notify method. Loaded by qt/scientific_interfaces/Direct/ALFPythonInstrumentView.cpp.

isisreflectometry/

ReflectometryInstrumentViewView is not a subclass; it is a minimal widget holding only a plotter, composed with FullInstrumentViewModel and ShapeRenderer. Its plotter is created lazily in initialise() to avoid OpenGL context errors before the widget is embedded, and resize-driven updates are debounced with a timer. It relays selection changes through a child QObject named ShapeChangedRelay. Loaded by qt/scientific_interfaces/ISISReflectometry/GUI/Preview/PreviewPythonInstrumentView.cpp.

Testing#

Tests live inside the package, in test subdirectories next to the code they cover. They are plain unittest test cases:

  • Model tests use real workspaces from CreateSampleWorkspace(StoreInADS=False).

  • Presenter tests use a MagicMock view. Where a mock stands in for the model, spec it against the real class so that a call to a method the model does not have fails the test.

  • View tests use @start_qapplication from mantidqt.utils.qt.testing, patch BackgroundPlotter and the matplotlib canvas, and patch force_method_calls_to_qapp_thread so that the @run_on_qapp_thread decorator does not interfere.

Run them with:

ctest -R python.instrumentview

Differences from the legacy widget#

Concern

Legacy (C++)

Current (Python)

Graphics

OpenGL, GLDisplay and GLObject

VTK through PyVista

Picking

Colour-encoded off-screen render

VTK pickers plus a cell-to-detector map

Geometry

A geometry handler per object

Detectors grouped by unique shape into one merged mesh

Projections

UnwrappedSurface and its subclasses

The Projections package

Shapes

Shape2D and Shape2DCollection

SelectionShape on a pv.Chart2D overlay

Line plot

MiniPlotMpl

Embedded matplotlib canvas with the Mantid toolbar

Project save

InstrumentWidgetEncoder

Not implemented

Settings persistence

None

A few ConfigService keys