Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Basic Instrument View functionality with PyVista #39081

Draft
wants to merge 22 commits into
base: main
Choose a base branch
from
Draft
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8f6d394
Instrument View 2.0 using pyvista
jclarkeSTFC Nov 11, 2024
631d881
Initial work on projections
jclarkeSTFC Dec 16, 2024
c2f83c6
Move code into new widget, make MVP
jclarkeSTFC Dec 19, 2024
592b476
Plot projection in lower view
jclarkeSTFC Dec 19, 2024
224d89b
Edit contour range using text boxes
jclarkeSTFC Dec 19, 2024
700d9ad
Add pyvista and pyvistaqt to developer environment
jclarkeSTFC Jan 6, 2025
13c496d
Implement time-of-flight filtering
jclarkeSTFC Jan 6, 2025
b1df0dc
Add projection selection combo box
jclarkeSTFC Jan 7, 2025
74ef426
Add new Instrument View to workspace context menu
jclarkeSTFC Jan 8, 2025
2505a44
Add many tests and fix a couple of problems
jclarkeSTFC Jan 10, 2025
f5ffc13
Update year on copyright notice
jclarkeSTFC Jan 16, 2025
33b689e
Add tests for projections
jclarkeSTFC Jan 17, 2025
a5d152b
Remove hard-coded file path from module
jclarkeSTFC Mar 11, 2025
3b43f39
Host detector plot inside the main widget.
jclarkeSTFC Mar 13, 2025
17fafe4
Add multi-select
jclarkeSTFC Mar 17, 2025
52baaa7
Set icon when launching module
jclarkeSTFC Mar 17, 2025
45e211c
Improve detector info when multiple selected
jclarkeSTFC Mar 17, 2025
2ad053d
Refactor projection classes and add tests
jclarkeSTFC Mar 19, 2025
c3f4e2f
Add doc strings and some tidying up.
jclarkeSTFC Mar 19, 2025
c373541
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 19, 2025
d4f22b9
Cppcheck fixes
jclarkeSTFC Mar 20, 2025
f0215dd
Inline function only used in one place.
jclarkeSTFC Mar 20, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Framework/API/inc/MantidAPI/MatrixWorkspace.h
Original file line number Diff line number Diff line change
@@ -363,6 +363,10 @@ class MANTID_API_DLL MatrixWorkspace : public IMDWorkspace, public ExperimentInf
virtual void getIntegratedSpectra(std::vector<double> &out, const double minX, const double maxX,
const bool entireRange) const;

/// Return a vector with the integrated counts for all spectra withing the
/// given range
std::vector<double> getIntegratedSpectra(const double minX, const double maxX, const bool entireRange);

/// Return an index in the X vector for an x-value close to a given value
std::pair<size_t, double> getXIndex(size_t i, double x, bool isLeft = true, size_t start = 0) const;

@@ -389,7 +393,7 @@ class MANTID_API_DLL MatrixWorkspace : public IMDWorkspace, public ExperimentInf
/// Returns true if the workspace has common, integer X bins
virtual bool isIntegerBins() const;

std::string YUnit() const;
const std::string &YUnit() const { return m_YUnit; }
void setYUnit(const std::string &newUnit);
std::string YUnitLabel(bool useLatex = false, bool plotAsDistribution = false) const;
void setYUnitLabel(const std::string &newLabel);
20 changes: 17 additions & 3 deletions Framework/API/src/MatrixWorkspace.cpp
Original file line number Diff line number Diff line change
@@ -785,6 +785,23 @@ void MatrixWorkspace::getXMinMax(double &xmin, double &xmax) const {
}
}

/** Integrate all the spectra in the matrix workspace within the range given.
* NaN and Infinite values are ignored.
* Default implementation, can be overridden by base classes if they know
*something smarter!
*
* @param minX :: minimum X bin to use in integrating.
* @param maxX :: maximum X bin to use in integrating.
* @param entireRange :: set to true to use the entire range. minX and maxX are
*then ignored!
*/
std::vector<double> MatrixWorkspace::getIntegratedSpectra(const double minX, const double maxX,
const bool entireRange) {
std::vector<double> integratedSpectra;
getIntegratedSpectra(integratedSpectra, minX, maxX, entireRange);
return integratedSpectra;
}

/** Integrate all the spectra in the matrix workspace within the range given.
* NaN and Infinite values are ignored.
* Default implementation, can be overridden by base classes if they know
@@ -1012,9 +1029,6 @@ bool MatrixWorkspace::isCommonLogBins() const {
*/
size_t MatrixWorkspace::numberOfAxis() const { return m_axes.size(); }

/// Returns the units of the data in the workspace
std::string MatrixWorkspace::YUnit() const { return m_YUnit; }

/// Sets a new unit for the data (Y axis) in the workspace
void MatrixWorkspace::setYUnit(const std::string &newUnit) { m_YUnit = newUnit; }

4 changes: 3 additions & 1 deletion Framework/Beamline/inc/MantidBeamline/ComponentType.h
Original file line number Diff line number Diff line change
@@ -6,8 +6,10 @@
// SPDX - License - Identifier: GPL - 3.0 +
#pragma once

#include <stdexcept>

namespace Mantid {
namespace Beamline {
enum class ComponentType { Generic, Infinite, Grid, Rectangular, Structured, Unstructured, Detector, OutlineComposite };
}
} // namespace Beamline
} // namespace Mantid
Original file line number Diff line number Diff line change
@@ -83,6 +83,7 @@ class MANTID_GEOMETRY_DLL ComponentInfo {
const std::vector<size_t> &children(size_t componentIndex) const;
size_t size() const;
QuadrilateralComponent quadrilateralComponent(const size_t componentIndex) const;
std::vector<size_t> quadrilateralComponentCornerIndices(const size_t componentIndex) const;
size_t indexOf(Geometry::IComponent *id) const;
size_t indexOfAny(const std::string &name) const;
bool uniqueName(const std::string &name) const;
@@ -128,6 +129,7 @@ class MANTID_GEOMETRY_DLL ComponentInfo {
BoundingBox boundingBox(const size_t componentIndex, const BoundingBox *reference = nullptr,
const bool excludeMonitors = false) const;
Beamline::ComponentType componentType(const size_t componentIndex) const;
std::string componentTypeName(const size_t componentIndex) const;
void setScanInterval(const std::pair<Types::Core::DateAndTime, Types::Core::DateAndTime> &interval);
size_t scanCount() const;
void merge(const ComponentInfo &other);
3 changes: 3 additions & 0 deletions Framework/Geometry/inc/MantidGeometry/Objects/CSGObject.h
Original file line number Diff line number Diff line change
@@ -166,6 +166,9 @@ class MANTID_GEOMETRY_DLL CSGObject final : public IObject {
std::shared_ptr<GeometryHandler> getGeometryHandler() const override;
/// Set Geometry Handler
void setGeometryHandler(const std::shared_ptr<GeometryHandler> &h);
std::string getGeometryShape() const;
const std::vector<Kernel::V3D> getGeometryPoints() const;
const std::vector<double> getGeometryDimensions() const;

/// set vtkGeometryCache writer
void setVtkGeometryCacheWriter(std::shared_ptr<vtkGeometryCacheWriter>);
29 changes: 29 additions & 0 deletions Framework/Geometry/src/Instrument/ComponentInfo.cpp
Original file line number Diff line number Diff line change
@@ -126,6 +126,11 @@ ComponentInfo::QuadrilateralComponent ComponentInfo::quadrilateralComponent(cons
return corners;
}

std::vector<size_t> ComponentInfo::quadrilateralComponentCornerIndices(const size_t componentIndex) const {
const auto quadrilateral = quadrilateralComponent(componentIndex);
return {quadrilateral.topLeft, quadrilateral.bottomLeft, quadrilateral.bottomRight, quadrilateral.topRight};
}

size_t ComponentInfo::indexOf(Geometry::IComponent *id) const { return m_compIDToIndex->at(id); }

size_t ComponentInfo::indexOfAny(const std::string &name) const { return m_componentInfo->indexOfAny(name); }
@@ -433,6 +438,30 @@ Beamline::ComponentType ComponentInfo::componentType(const size_t componentIndex
return m_componentInfo->componentType(componentIndex);
}

std::string ComponentInfo::componentTypeName(const size_t componentIndex) const {
const auto type = componentType(componentIndex);
switch (type) {
case Beamline::ComponentType::Generic:
return "Generic";
case Beamline::ComponentType::Infinite:
return "Infinite";
case Beamline::ComponentType::Grid:
return "Grid";
case Beamline::ComponentType::Rectangular:
return "Rectangular";
case Beamline::ComponentType::Structured:
return "Structured";
case Beamline::ComponentType::Unstructured:
return "Unstructured";
case Beamline::ComponentType::Detector:
return "Detector";
case Beamline::ComponentType::OutlineComposite:
return "OutlineComposite";
default:
throw std::invalid_argument("Unknown ComponentType type");
}
}

void ComponentInfo::setScanInterval(const std::pair<Types::Core::DateAndTime, Types::Core::DateAndTime> &interval) {
m_componentInfo->setScanInterval({interval.first.totalNanoseconds(), interval.second.totalNanoseconds()});
}
42 changes: 42 additions & 0 deletions Framework/Geometry/src/Objects/CSGObject.cpp
Original file line number Diff line number Diff line change
@@ -38,6 +38,7 @@
#include <deque>
#include <random>
#include <stack>
#include <stdexcept>
#include <unordered_set>
#include <utility>

@@ -2120,6 +2121,47 @@ std::shared_ptr<GeometryHandler> CSGObject::getGeometryHandler() const {
return m_handler;
}

std::string CSGObject::getGeometryShape() const {
Geometry::detail::ShapeInfo::GeometryShape geometryShape;
std::vector<V3D> points;
double radius, innerRadius, height = 0;
m_handler->GetObjectGeom(geometryShape, points, innerRadius, radius, height);
switch (geometryShape) {
case detail::ShapeInfo::GeometryShape::NOSHAPE:
return "NOSHAPE";
case detail::ShapeInfo::GeometryShape::CUBOID:
return "CUBOID";
case detail::ShapeInfo::GeometryShape::HEXAHEDRON:
return "HEXAHEDRON";
case detail::ShapeInfo::GeometryShape::SPHERE:
return "SPHERE";
case detail::ShapeInfo::GeometryShape::CYLINDER:
return "CYLINDER";
case detail::ShapeInfo::GeometryShape::CONE:
return "CONE";
case detail::ShapeInfo::GeometryShape::HOLLOWCYLINDER:
return "HOLLOWCYLINDER";
default:
throw std::invalid_argument("Unknown GeometryShape");
}
}

const std::vector<V3D> CSGObject::getGeometryPoints() const {
Geometry::detail::ShapeInfo::GeometryShape geometryShape;
std::vector<V3D> points;
double radius, innerRadius, height = 0;
m_handler->GetObjectGeom(geometryShape, points, innerRadius, radius, height);
return points;
}

const std::vector<double> CSGObject::getGeometryDimensions() const {
Geometry::detail::ShapeInfo::GeometryShape geometryShape;
std::vector<V3D> points;
double radius, innerRadius, height = 0;
m_handler->GetObjectGeom(geometryShape, points, innerRadius, radius, height);
return {innerRadius, radius, height};
}

/**
* Updates the geometry handler if needed
*/
Original file line number Diff line number Diff line change
@@ -174,6 +174,22 @@ void setDxFromPyObject(MatrixWorkspace &self, const size_t wsIndex, const boost:
setSpectrumFromPyObject(self, &MatrixWorkspace::dataDx, wsIndex, values);
}

/** Integrate all the spectra in the matrix workspace within the range given.
* NaN and Infinite values are ignored.
* Default implementation, can be overridden by base classes if they know
*something smarter!
*
* @param minX :: minimum X bin to use in integrating.
* @param maxX :: maximum X bin to use in integrating.
* @param entireRange :: set to true to use the entire range. minX and maxX are
*then ignored!
*/
std::vector<double> getIntegratedSpectra(MatrixWorkspace &self, const double minX, const double maxX,
const bool entireRange) {
// Need a wrapper here to deal with the overload
return self.getIntegratedSpectra(minX, maxX, entireRange);
}

/**
* Adds a deprecation warning to the getSampleDetails call to warn about using
* getRun instead
@@ -370,7 +386,7 @@ void export_MatrixWorkspace() {
"Returns ``True`` if this is considered to be binned data.")
.def("isDistribution", (bool(MatrixWorkspace::*)() const) & MatrixWorkspace::isDistribution, arg("self"),
"Returns the status of the distribution flag")
.def("YUnit", &MatrixWorkspace::YUnit, arg("self"),
.def("YUnit", &MatrixWorkspace::YUnit, return_value_policy<copy_const_reference>(), arg("self"),
"Returns the current Y unit for the data (Y axis) in the workspace")
.def("YUnitLabel", &MatrixWorkspace::YUnitLabel,
MatrixWorkspace_YUnitLabelOverloads((arg("self"), arg("useLatex"), arg("plotAsDistribution")),
@@ -500,6 +516,8 @@ void export_MatrixWorkspace() {
"of memory free that will fit all of the data.")
.def("getSignalAtCoord", &getSignalAtCoord, args("self", "coords", "normalization"),
"Return signal for array of coordinates")
.def("getIntegratedSpectra", &getIntegratedSpectra, args("self", "minX", "maxX", "entireRange"),
"Return a vector with the integrated counts for all spectra withing the given range")
//-------------------------------------- Operators
//-----------------------------------
.def("equals", &Mantid::API::equals, args("self", "other", "tolerance"),
Original file line number Diff line number Diff line change
@@ -63,5 +63,11 @@ void export_Object() {

.def("volume", &CSGObject::volume, arg("self"), "Returns the volume of this shape.")

.def("getMesh", &wrapMeshWithNDArray, (arg("self")), "Get the vertices, grouped by triangles, from mesh");
.def("getMesh", &wrapMeshWithNDArray, (arg("self")), "Get the vertices, grouped by triangles, from mesh")

.def("getGeometryShape", &CSGObject::getGeometryShape, arg("self"), "")

.def("getGeometryPoints", &CSGObject::getGeometryPoints, arg("self"), "")

.def("getGeometryDimensions", &CSGObject::getGeometryDimensions, arg("self"), "");
}
Original file line number Diff line number Diff line change
@@ -144,5 +144,11 @@ void export_ComponentInfo() {
.def("uniqueName", &ComponentInfo::uniqueName, (arg("self"), arg("name")),
"Returns True if the name is a unique single occurance. Zero occurances yields False.")

.def("root", &ComponentInfo::root, arg("self"), "Returns the index of the root component");
.def("root", &ComponentInfo::root, arg("self"), "Returns the index of the root component")

.def("quadrilateralComponentCornerIndices", &ComponentInfo::quadrilateralComponentCornerIndices,
(arg("self"), arg("componentIndex")), "Returns indices of the vertices of a quadrilateral component")

.def("componentTypeName", &ComponentInfo::componentTypeName, (arg("self"), arg("componentIndex")),
"Returns the type of the specified component");
}
9 changes: 9 additions & 0 deletions Framework/PythonInterface/mantid/kernel/src/Exports/Quat.cpp
Original file line number Diff line number Diff line change
@@ -21,6 +21,14 @@ using boost::python::return_value_policy;
using Mantid::Kernel::Quat;
using Mantid::Kernel::V3D;

/// Extracts the angle of rotation and axis
/// @returns The angle of rotation in degrees and the three components of the axis
std::vector<double> getAngleAxis(Quat &self) {
double angle, x, y, z = 0;
self.getAngleAxis(angle, x, y, z);
return {angle, x, y, z};
}

/**
* Python exports of the Mantid::Kernel::Quat class.
*/
@@ -52,6 +60,7 @@ void export_Quat() {
.def("len2", &Quat::len2, arg("self"), "Returns the square of the 'length' of the quaternion")
.def("getEulerAngles", &Quat::getEulerAngles, (arg("self"), arg("convention") = "YZX"),
"Default convention is \'YZX\'.")
.def("getAngleAxis", &getAngleAxis, arg("self"), "Extracts the angle of rotation and the axis")
// cppcheck-suppress syntaxError
.def("__add__", &Quat::operator+, (arg("left"), arg("right")))
.def("__iadd__", &Quat::operator+=, boost::python::return_self<>(), (arg("self"), arg("other")))
14 changes: 14 additions & 0 deletions Framework/PythonInterface/mantid/kernel/src/Exports/V3D.cpp
Original file line number Diff line number Diff line change
@@ -28,6 +28,18 @@ namespace {
*/
V3D directionAnglesDefault(V3D &self) { return self.directionAngles(); }

/** Return the vector's position in spherical coordinates
* R :: Returns the radial distance
* theta :: Returns the theta angle in degrees
* phi :: Returns the phi (azimuthal) angle in degrees
* @return R, theta (degrees), phi (azimuthal, degrees)
*/
std::vector<double> getSpherical(V3D &self) {
double R, theta, phi = 0;
self.getSpherical(R, theta, phi);
return {R, theta, phi};
}

Py_hash_t hashV3D(V3D &self) {
boost::python::object tmpObj(self.toString());

@@ -120,6 +132,8 @@ void export_V3D() {
"Computes the cross product between this and another vector")
.def("norm", &V3D::norm, arg("self"), "Calculates the length of the vector")
.def("norm2", &V3D::norm2, arg("self"), "Calculates the squared length of the vector")
.def("getSpherical", &getSpherical, arg("self"), "Return the vector's position in spherical coordinates")
// cppcheck-suppress syntaxError
.def("__add__", &V3D::operator+, (arg("left"), arg("right")))
.def("__iadd__", &V3D::operator+=, return_self<>(), (arg("self"), arg("other")))
.def("__sub__", static_cast<V3D (V3D::*)(const V3D &) const>(&V3D::operator-), (arg("left"), arg("right")))
3 changes: 1 addition & 2 deletions buildconfig/CMake/CppCheck_Suppressions.txt.in
Original file line number Diff line number Diff line change
@@ -421,7 +421,7 @@ shadowFunction:${CMAKE_SOURCE_DIR}/Framework/Geometry/src/Instrument.cpp:629
shadowFunction:${CMAKE_SOURCE_DIR}/Framework/Geometry/src/Instrument.cpp:642
constVariablePointer:${CMAKE_SOURCE_DIR}/Framework/Geometry/src/Instrument/CompAssembly.cpp:376
constVariablePointer:${CMAKE_SOURCE_DIR}/Framework/Geometry/src/Instrument/CompAssembly.cpp:405
constParameterPointer:${CMAKE_SOURCE_DIR}/Framework/Geometry/src/Instrument/ComponentInfo.cpp:129
constParameterPointer:${CMAKE_SOURCE_DIR}/Framework/Geometry/src/Instrument/ComponentInfo.cpp:134
constVariableReference:${CMAKE_SOURCE_DIR}/Framework/Geometry/src/Rendering/RenderingHelpersOpenGL.cpp:204
constParameterPointer:${CMAKE_SOURCE_DIR}/Framework/Geometry/src/Rendering/vtkGeometryCacheReader.cpp:64
constParameterPointer:${CMAKE_SOURCE_DIR}/Framework/Geometry/src/Rendering/vtkGeometryCacheWriter.cpp:83
@@ -554,7 +554,6 @@ unusedScopedObject:${CMAKE_SOURCE_DIR}/Framework/PythonInterface/mantid/api/src/
constParameterCallback:${CMAKE_SOURCE_DIR}/Framework/PythonInterface/mantid/api/src/Exports/WorkspaceGroup.cpp:80
unusedScopedObject:${CMAKE_SOURCE_DIR}/Framework/PythonInterface/mantid/api/src/Exports/WorkspaceGroup.cpp:113
syntaxError:${CMAKE_SOURCE_DIR}/Framework/PythonInterface/mantid/dataobjects/src/Exports/EventList.cpp:37
syntaxError:${CMAKE_SOURCE_DIR}/Framework/PythonInterface/mantid/kernel/src/Exports/V3D.cpp:123
syntaxError:${CMAKE_SOURCE_DIR}/Framework/PythonInterface/mantid/kernel/src/Exports/VMD.cpp:100
constVariablePointer:${CMAKE_SOURCE_DIR}/Framework/Reflectometry/src/CreateTransmissionWorkspace2.cpp:176
constVariablePointer:${CMAKE_SOURCE_DIR}/Framework/Reflectometry/src/CreateTransmissionWorkspace2.cpp:177
2 changes: 2 additions & 0 deletions conda/recipes/mantid-developer/meta.yaml
Original file line number Diff line number Diff line change
@@ -41,6 +41,8 @@ requirements:
- python-dateutil {{ python_dateutil }}
- python {{ python }}
- python.app # [osx]
- pyvista
- pyvistaqt
- pyyaml {{ pyyaml }}
- qscintilla2 {{ qscintilla2 }}
- qt {{ qt_main }}
Original file line number Diff line number Diff line change
@@ -37,6 +37,8 @@
MATRIXWORKSPACE_DISPLAY_TYPE = "StatusBarView"
SAMPLE_MATERIAL_DIALOG_TYPE = "SampleMaterialDialogView"
SAMPLE_MATERIAL_DIALOG = "mantidqt.widgets.samplematerialdialog.samplematerial_view." + SAMPLE_MATERIAL_DIALOG_TYPE
INSTRUMENT_VIEW_WINDOW_TYPE = "FullInstrumentViewWindow"
INSTRUMENT_VIEW_DIALOG = "instrumentview.FullInstrumentViewWindow." + INSTRUMENT_VIEW_WINDOW_TYPE


@start_qapplication
@@ -117,7 +119,6 @@ def test_detector_table_shows_with_a_workspace_missing_an_efixed(self):

@mock.patch("workbench.plugins.workspacewidget.plot", autospec=True)
def test_plot_with_plot_bin(self, mock_plot):
self.ws_widget._ads.add(self.ws_names[0], self.w_spaces[0])
self.ws_widget._do_plot_bin([self.ws_names[0]], False, False)
mock_plot.assert_called_once_with(mock.ANY, errors=False, overplot=False, wksp_indices=[0], plot_kwargs={"axis": MantidAxType.BIN})

@@ -200,11 +201,39 @@ def test_show_sample_doesnt_plot_with_multiple_workspace_names(self, mock_plot_s
self.ws_widget._show_sample_shape(self.ws_names)
mock_plot_sample_container_and_components.assert_not_called()

@mock.patch(INSTRUMENT_VIEW_DIALOG + ".show")
def test_new_instrument_view_opens_with_single_workspace_name(self, mock_show):
"""
New Instrument View should work with a single workspace selected
"""
single_ws_list = [self.ws_names[0]]
self.ws_widget._do_show_new_instrument_view(single_ws_list, off_screen=True)
mock_show.assert_called_once()

@mock.patch(INSTRUMENT_VIEW_DIALOG + ".show")
def test_new_instrument_view_opens_with_multiple_workspace_names(self, mock_show):
"""
New Instrument View should work with multiple workspaces selected
"""
workspaces = [self.ws_names[0], self.ws_names[0]]
self.ws_widget._do_show_new_instrument_view(workspaces, off_screen=True)
self.assertEqual(mock_show.call_count, len(workspaces))

def test_empty_workspaces(self):
self.ws_widget._ads.clear()
def mock_getEmptyObjectNames():
return []

def mock_getSingleObjectNames():
return ["ws"]

original_ads = self.ws_widget._ads
mock_ads = mock.MagicMock()
mock_ads.getObjectNames = mock_getEmptyObjectNames
self.ws_widget._ads = mock_ads
self.assertEqual(self.ws_widget.empty_of_workspaces(), True)
CreateSampleWorkspace(OutputWorkspace="ws")
mock_ads.getObjectNames = mock_getSingleObjectNames
self.assertEqual(self.ws_widget.empty_of_workspaces(), False)
self.ws_widget._ads = original_ads


if __name__ == "__main__":
19 changes: 19 additions & 0 deletions qt/applications/workbench/workbench/plugins/workspacewidget.py
Original file line number Diff line number Diff line change
@@ -9,6 +9,7 @@
from functools import partial
from qtpy.QtWidgets import QApplication, QVBoxLayout

from instrumentview.FullInstrumentViewWindow import FullInstrumentViewWindow
from mantid.api import AnalysisDataService, WorkspaceGroup
from mantid.kernel import logger
from mantidqt.plotting import functions
@@ -69,6 +70,7 @@ def __init__(self, parent):
self.workspacewidget.sliceViewerClicked.connect(self._do_slice_viewer)
self.workspacewidget.showDataClicked.connect(self._do_show_data)
self.workspacewidget.showInstrumentClicked.connect(self._do_show_instrument)
self.workspacewidget.showNewInstrumentViewClicked.connect(self._do_show_new_instrument_view)
self.workspacewidget.showAlgorithmHistoryClicked.connect(self._do_show_algorithm_history)
self.workspacewidget.showDetectorsClicked.connect(self._do_show_detectors)
self.workspacewidget.plotAdvancedClicked.connect(partial(self._do_plot_spectrum, errors=False, overplot=False, advanced=True))
@@ -290,6 +292,23 @@ def _do_show_instrument(self, names):
else:
logger.warning("Could not show instrument for workspace '{}':\nNo instrument available.\n".format(ws.name()))

def _do_show_new_instrument_view(self, names, off_screen=False):
"""
Show the updated instrument view for the given workspaces
:param names: A list of workspace names
"""
parent, _ = get_window_config()
for ws in self._ads.retrieveWorkspaces(names, unrollGroups=True):
if ws.getInstrument().getName():
try:
view = FullInstrumentViewWindow(ws, parent=parent, off_screen=off_screen)
view.show()
except Exception as exception:
logger.warning("Could not show instrument for workspace '{}':\n{}\n".format(ws.name(), exception))
else:
logger.warning("Could not show instrument for workspace '{}':\nNo instrument available.\n".format(ws.name()))

def _do_show_data(self, names):
# local import to allow this module to be imported without pyplot being imported
import matplotlib.pyplot
1 change: 1 addition & 0 deletions qt/python/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
add_subdirectory(mantidqt)
add_subdirectory(mantidqtinterfaces)
add_subdirectory(instrumentview)
14 changes: 14 additions & 0 deletions qt/python/instrumentview/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
include(PythonPackageTargetFunctions)

add_python_package(instrumentview)

# ctest targets
set(PYTHON_TEST_FILES
instrumentview/test/test_presenter.py instrumentview/test/test_model.py instrumentview/test/test_instruments.py
instrumentview/Projections/test/test_projection.py instrumentview/Projections/test/test_cylindrical_projection.py
instrumentview/Projections/test/test_spherical_projection.py
)

# Tests
set(PYUNITTEST_QT_API pyqt5)
pyunittest_add_test(${CMAKE_CURRENT_SOURCE_DIR} python.instrumentview ${PYTHON_TEST_FILES})
1 change: 1 addition & 0 deletions qt/python/instrumentview/MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
include mantidplot.png
29 changes: 29 additions & 0 deletions qt/python/instrumentview/instrumentview/DetectorInfo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Mantid Repository : https://github.com/mantidproject/mantid
#
# Copyright &copy; 2025 ISIS Rutherford Appleton Laboratory UKRI,
# NScD Oak Ridge National Laboratory, European Spallation Source,
# Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS
# SPDX - License - Identifier: GPL - 3.0 +
import numpy as np


class DetectorInfo:
"""Class for wrapping up information relating to a detector. Used for transferring this info to the View to be displayed."""

def __init__(
self,
name: str,
detector_id: int,
workspace_index: int,
xyz_position: np.ndarray,
spherical_position: np.ndarray,
component_path: str,
pixel_counts: int,
):
self.name = name
self.detector_id = detector_id
self.workspace_index = workspace_index
self.xyz_position = xyz_position
self.spherical_position = spherical_position
self.component_path = component_path
self.pixel_counts = pixel_counts
293 changes: 293 additions & 0 deletions qt/python/instrumentview/instrumentview/FullInstrumentViewModel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
# Mantid Repository : https://github.com/mantidproject/mantid
#
# Copyright &copy; 2025 ISIS Rutherford Appleton Laboratory UKRI,
# NScD Oak Ridge National Laboratory, European Spallation Source,
# Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS
# SPDX - License - Identifier: GPL - 3.0 +
from instrumentview.DetectorInfo import DetectorInfo
import instrumentview.Projections.spherical_projection as iv_spherical
import instrumentview.Projections.cylindrical_projection as iv_cylindrical
import numpy as np
import math
import pyvista as pv
from scipy.spatial.transform import Rotation


class DetectorPosition(np.ndarray):
"""A numpy array that uses a tolerance on the data to check for equality"""

def __new__(cls, input_array):
return np.asarray(input_array).view(cls)

def __eq__(self, other):
return np.allclose(self, other)


class FullInstrumentViewModel:
"""Model for the Instrument View Window. Will calculate detector positions, indices, and integrated counts that give the colours"""

_sample_position = np.array([0, 0, 0])
_source_position = np.array([0, 0, 0])
_invalid_index = -1
_data_min = 0.0
_data_max = 0.0

def __init__(self, workspace, draw_detector_geometry: bool):
"""For the given workspace, calculate detector positions, the map from detector indices to workspace indices, and integrated
counts. Optionally will draw detector geometry, e.g. rectangular bank or tube instead of points."""
self._workspace = workspace
self._detector_info = workspace.detectorInfo()

self._component_info = workspace.componentInfo()
self._sample_position = np.array(self._component_info.samplePosition())

has_source = workspace.getInstrument().getSource() is not None
self._source_position = np.array(self._component_info.sourcePosition()) if has_source else np.array([0, 0, 0])

self._detector_indices = []
self._monitor_positions = []
self._monitor_indices = []
component_meshes = []
component_mesh_colours = []

self._detector_index_to_workspace_index = np.full(len(self._component_info), self._invalid_index, dtype=int)
spectrum_info = workspace.spectrumInfo()
self._bin_min = math.inf
self._bin_max = -math.inf
for workspace_index in range(workspace.getNumberHistograms()):
x_data = self._workspace.dataX(workspace_index)
self._union_with_current_bin_min_max(x_data[0])
self._union_with_current_bin_min_max(x_data[-1])
spectrum_definition = spectrum_info.getSpectrumDefinition(workspace_index)
for i in range(len(spectrum_definition)):
pair = spectrum_definition[i]
self._detector_index_to_workspace_index[pair[0]] = workspace_index

for component_index in range(self._component_info.root(), -1, -1):
component_type = self._component_info.componentTypeName(component_index)
match component_type:
case "Infinite":
continue
case "Grid":
continue
case "Rectangular":
rectangular_bank_mesh = self.drawRectangularBank(self._component_info, component_index)
component_meshes.append(rectangular_bank_mesh)
component_mesh_colours.append((0.1, 0.1, 0.1, 0.2))
continue
case "OutlineComposite":
outline_composite_mesh = self.drawSingleDetector(self._component_info, component_index)
component_meshes.append(outline_composite_mesh)
component_mesh_colours.append((0.1, 0.1, 0.1, 0.2))
continue
case "Structured":
structured_mesh = self.drawStructuredBank(self._component_info, component_index)
component_meshes.append(structured_mesh)
component_mesh_colours.append((0.1, 0.1, 0.1, 0.2))
continue
case _:
if not self._component_info.isDetector(component_index):
continue
if self._detector_info.isMonitor(component_index):
self._monitor_positions.append(self._detector_info.position(component_index))
self._monitor_indices.append(component_index)
elif self._component_info.hasValidShape(component_index):
self._detector_indices.append(component_index)
if draw_detector_geometry:
component_meshes.append(self.drawSingleDetector(self._component_info, component_index))
else:
continue

self._detector_counts = []
self.update_time_of_flight_range(self._bin_min, self._bin_max, True)
self._detector_position_map = {id: DetectorPosition(self._component_info.position(id)) for id in self._detector_indices}

def _union_with_current_bin_min_max(self, bin_edge) -> None:
"""Expand current bin limits to include new bin edge"""
if not math.isinf(bin_edge):
if bin_edge < self._bin_min:
self._bin_min = bin_edge
elif bin_edge > self._bin_max:
self._bin_max = bin_edge

def update_time_of_flight_range(self, tof_min: float, tof_max: float, entire_range=False) -> None:
"""Calculate integrated counts for the specified TOF range"""
integrated_spectra = self._workspace.getIntegratedSpectra(tof_min, tof_max, entire_range)
self._detector_counts.clear()
for det_index in self._detector_indices:
workspace_index = int(self._detector_index_to_workspace_index[det_index])
if workspace_index == self._invalid_index or det_index in self._monitor_indices:
continue
self._detector_counts.append(integrated_spectra[workspace_index])
self._data_max = max(self._data_max, integrated_spectra[workspace_index])
self._data_min = min(self._data_min, integrated_spectra[workspace_index])

def workspace(self):
return self._workspace

def workspace_index_from_detector_index(self, detector_index: int) -> int:
return int(self._detector_index_to_workspace_index[detector_index])

def sample_position(self) -> np.ndarray:
return self._sample_position

def detector_positions(self) -> list:
return list(self._detector_position_map.values())

def detector_counts(self) -> list:
return self._detector_counts

def detector_index(self, i: int) -> int:
return self._detector_indices[i]

def data_limits(self) -> list:
return [self._data_min, self._data_max]

def bin_limits(self) -> list:
return [self._bin_min, self._bin_max]

def monitor_positions(self) -> list:
return self._monitor_positions

def drawRectangularBank(self, component_info, component_index: int) -> pv.PolyData:
"""Create a mesh representing a rectangle"""
corner_indices = component_info.quadrilateralComponentCornerIndices(component_index)
corner_positions = [np.array(component_info.position(corner_index)) for corner_index in corner_indices]
scale = np.array(component_info.scaleFactor(component_index))
corner_positions = corner_positions * scale
# Number of points for each face, followed by the indices of the vertices
faces = [4, 0, 1, 2, 3]
return pv.PolyData(corner_positions, faces)

def drawStructuredBank(self, component_info, component_index: int) -> pv.PolyData:
"""Create a mesh for a general shape"""
bank_corner_indices = component_info.quadrilateralComponentCornerIndices(component_index)
bank_corner_positions = [np.array(component_info.position(corner_index)) for corner_index in bank_corner_indices]
shape = component_info.shape(component_index)
shape_points = [np.array(point) for point in shape.getGeometryPoints()]
position = np.array(shape_points[0])
position[2] = bank_corner_positions[1][2] # Bottom-left Z

columns = component_info.children(component_index)
column_width = len(columns) * 3
base_column_index = component_info(columns[0])[0]
base_shape = component_info.shape(base_column_index)
base_points = base_shape.getGeometryPoints()
base_position = np.array(base_points[0])

vertices = []
# faces = []

for index in range(0, column_width, 3):
column_index = int(index / 3)
column = component_info.children(columns[column_index])
for column_child_index in len(column):
y = column[column_child_index]
child_shape = component_info.shape(y)
child_position = np.array(component_info.position(y))
child_rotation = component_info.rotation(y).getAngleAxis()
rotation = Rotation.from_rotvec(
child_rotation[0] * np.array([child_rotation[1], child_rotation[2], child_rotation[3]]), degrees=True
)
child_shape_points = child_shape.getGeometryPoints()
child_shape_points = [np.array(rotation.apply(p)) + position - base_position + child_position for p in child_shape_points]
vertices.append(child_shape_points)

return pv.PolyData(vertices)

def drawSingleDetector(self, component_info, component_index: int):
"""Create an appropriate mesh for a detector based on the shape type"""
detector_position = np.array(component_info.position(component_index))
mantid_rotation = component_info.rotation(component_index).getAngleAxis()
rotation = Rotation.from_rotvec(
mantid_rotation[0] * np.array([mantid_rotation[1], mantid_rotation[2], mantid_rotation[3]]), degrees=True
)
scale = np.array(component_info.scaleFactor(component_index))
shape = component_info.shape(component_index)
shape_type = shape.getGeometryShape()
shape_points = [np.array(point) for point in shape.getGeometryPoints()]
shape_dimensions = shape.getGeometryDimensions()
match shape_type:
case "SPHERE":
centre = shape_points[0] + detector_position
centre = rotation.apply(centre)
centre = np.multiply(centre, scale)
radius = shape_dimensions[1]
return pv.Sphere(radius, centre)
case "CUBOID":
vec0 = shape_points[0] + detector_position
vec1 = shape_points[1] - shape_points[0]
vec2 = shape_points[2] - shape_points[0]
vec3 = shape_points[3] - shape_points[0]
hex_points = [
vec0,
vec0 + vec3,
vec0 + vec3 + vec1,
vec0 + vec1,
vec0 + vec1,
vec0 + vec2 + vec3,
vec0 + vec2 + vec3 + vec1,
vec0 + vec1 + vec2,
]
hex_points = [vertex * scale for vertex in hex_points]
connectivity = np.array([8, 0, 1, 2, 3, 4, 5, 6, 7])
return pv.UnstructuredGrid(connectivity, [pv.CellType.HEXAHEDRON], hex_points)
case "HEXAHEDRON":
hex_points = [(point + detector_position) * scale for point in shape_points]
connectivity = np.array([8, 0, 1, 2, 3, 4, 5, 6, 7])
return pv.UnstructuredGrid(connectivity, [pv.CellType.HEXAHEDRON], hex_points)
case "CONE" | "CYLINDER":
centre = shape_points[0] + detector_position
cylinder_axis = shape_points[1]
radius = shape_dimensions[1]
height = shape_dimensions[2]
centre = centre + 0.5 * height * cylinder_axis
cylinder_rotation = Rotation.concatenate([Rotation.align_vectors([0, 0, 1], cylinder_axis)[0], rotation])
cylinder_axis = cylinder_rotation.apply(cylinder_axis)[1]
centre = np.multiply(centre, scale)
cylinder_axis = np.multiply(cylinder_axis, scale)
if shape_type == "CYLINDER":
return pv.Cylinder(centre, cylinder_axis, radius, height, 20)
return pv.Cone(centre, cylinder_axis, height, radius)
case _:
mesh = shape.getMesh()
if len(mesh) == 0:
return None
mesh_points = np.array([rotation.apply(p) + detector_position for p in mesh.reshape(-1, 3)])
faces = np.hstack((3 * np.ones((len(mesh), 1)), np.arange(mesh_points.shape[0]).reshape(-1, 3))).astype(int)
return pv.PolyData(mesh_points, faces)

def get_detector_info_text(self, detector_index: int) -> DetectorInfo:
"""For the specified detector, extract info that can be displayed in the View, and wrap it all up in a DetectorInfo class"""
workspace_index = self.workspace_index_from_detector_index(detector_index)
name = self._component_info.name(detector_index)
detector_id = self._detector_info.detectorIDs()[detector_index]
xyz_position = self._component_info.position(detector_index)
spherical_position = xyz_position.getSpherical()

component_path = ""
pixel_counts = 0
if self._component_info.hasParent(detector_index):
parent = detector_index
component_path = ""
while self._component_info.hasParent(parent):
parent = self._component_info.parent(parent)
component_path = "/" + self._component_info.name(parent) + component_path
pixel_counts = self.detector_counts()[detector_index]

return DetectorInfo(
name, detector_id, workspace_index, np.array(xyz_position), np.array(spherical_position), component_path, int(pixel_counts)
)

def calculate_projection(self, is_spherical: bool, axis: np.ndarray) -> list[list[float]]:
"""Calculate the 2D projection with the specified axis. Can be either cylindrical or spherical."""
projection = (
iv_spherical.spherical_projection(self._workspace, self._detector_indices, axis)
if is_spherical
else iv_cylindrical.cylindrical_projection(self._workspace, self._detector_indices, axis)
)
projection_points = []
for det_id in range(len(self._detector_indices)):
x, y = projection.coordinate_for_detector(det_id)
projection_points.append([x, y, 0])
return projection_points
144 changes: 144 additions & 0 deletions qt/python/instrumentview/instrumentview/FullInstrumentViewPresenter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# Mantid Repository : https://github.com/mantidproject/mantid
#
# Copyright &copy; 2025 ISIS Rutherford Appleton Laboratory UKRI,
# NScD Oak Ridge National Laboratory, European Spallation Source,
# Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS
# SPDX - License - Identifier: GPL - 3.0 +
from instrumentview.FullInstrumentViewModel import FullInstrumentViewModel
from collections.abc import Iterable
import numpy as np
import pyvista as pv


class FullInstrumentViewPresenter:
"""Presenter for the Instrument View window"""

_SPHERICAL_X = "Spherical X"
_SPHERICAL_Y = "Spherical Y"
_SPHERICAL_Z = "Spherical Z"
_CYLINDRICAL_X = "Cylindrical X"
_CYLINDRICAL_Y = "Cylindrical Y"
_CYLINDRICAL_Z = "Cylindrical Z"
_SIDE_BY_SIDE = "Side-By-Side"
_PROJECTION_OPTIONS = [_SPHERICAL_X, _SPHERICAL_Y, _SPHERICAL_Z, _CYLINDRICAL_X, _CYLINDRICAL_Y, _CYLINDRICAL_Z, _SIDE_BY_SIDE]

def __init__(self, view, workspace):
"""For the given workspace, use the data from the model to plot the detectors. Also include points at the origin and
any monitors."""
pv.global_theme.color_cycler = "default"
pv.global_theme.allow_empty_mesh = True

self._view = view
self._model = FullInstrumentViewModel(workspace, draw_detector_geometry=False)

# Plot orange sphere at the origin
origin = pv.Sphere(radius=0.01, center=[0, 0, 0])
self._view.add_simple_shape(origin, colour="orange", pickable=False)

self._view.enable_point_picking(callback=self.point_picked)
self._view.show_axes()
self._view.set_camera_focal_point(self._model.sample_position())

self._counts_label = "Integrated Counts"
self._detector_mesh = self.createPolyDataMesh(self._model.detector_positions())
self._detector_mesh[self._counts_label] = self._model.detector_counts()
self._contour_limits = [self._model.data_limits()[0], self._model.data_limits()[1]]

self._view.add_mesh(self._detector_mesh, scalars=self._counts_label, clim=self._contour_limits, pickable=True)
self._view.set_contour_range_limits(self._contour_limits)

self._bin_limits = [self._model.bin_limits()[0], self._model.bin_limits()[1]]
self._view.set_tof_range_limits(self._bin_limits)

if len(self._model.monitor_positions()) > 0:
monitor_point_cloud = self.createPolyDataMesh(self._model.monitor_positions())
monitor_point_cloud["colours"] = self.generateSingleColour(self._model.monitor_positions(), 1, 0, 0, 1)
self._view.add_rgba_mesh(monitor_point_cloud, scalars="colours")

self.projection_option_selected(0)

def projection_combo_options(self) -> list[str]:
return self._PROJECTION_OPTIONS

def projection_option_selected(self, selected_index: int) -> None:
"""Update the projection based on the selected option."""
projection_type = self._PROJECTION_OPTIONS[selected_index]
if projection_type.startswith("Spherical"):
is_spherical = True
elif projection_type.startswith("Cylindrical"):
is_spherical = False
elif projection_type == self._SIDE_BY_SIDE:
pass
else:
raise ValueError(f"Unknown projection type: {projection_type}")

if projection_type.endswith("X"):
axis = [1, 0, 0]
elif projection_type.endswith("Y"):
axis = [0, 1, 0]
elif projection_type.endswith("Z"):
axis = [0, 0, 1]
else:
raise ValueError(f"Unknown projection type {projection_type}")

projection = self._model.calculate_projection(is_spherical, axis)
projection_mesh = self.createPolyDataMesh(projection)
projection_mesh[self._counts_label] = self._model.detector_counts()
self._view.add_projection_mesh(projection_mesh, self._counts_label, clim=self._contour_limits)

def set_contour_limits(self, min: int, max: int) -> None:
self._contour_limits = [min, max]
self._view.update_scalar_range(self._contour_limits, self._counts_label)

def set_tof_limits(self, min: int, max: int) -> None:
self._model.update_time_of_flight_range(min, max)
self._detector_mesh[self._counts_label] = self._model.detector_counts()
self.set_contour_limits(self._model.data_limits()[0], self._model.data_limits()[1])

def point_picked(self, point, picker):
"""For the given point, get the detector index and show all the information for that detector"""
if point is None:
return
point_index = picker.GetPointId()
detector_index = self._model.detector_index(point_index)
self.show_plot_for_detectors([detector_index])
self.show_info_text_for_detectors([detector_index])

def set_multi_select_enabled(self, is_enabled: bool) -> None:
"""Change between single and multi point picking"""
if is_enabled:
self._view.enable_rectangle_picking(callback=self.rectangle_picked)
else:
self._view.enable_point_picking(callback=self.point_picked)

def rectangle_picked(self, rectangle):
"""Get points within the selection rectangle and display information for those detectors"""
selected_points = rectangle.frustum_mesh.points
points = set([self._detector_mesh.find_closest_point(p) for p in selected_points])
self.show_plot_for_detectors(points)
self.show_info_text_for_detectors(points)

def createPolyDataMesh(self, points, faces=None) -> pv.PolyData:
"""Create a PyVista mesh from the given points and faces"""
mesh = pv.PolyData(points, faces)
return mesh

def generateSingleColour(self, points, red: float, green: float, blue: float, alpha: float) -> np.ndarray:
"""Returns an RGBA colours array for the given set of points, with all points the same colour"""
rgba = np.zeros((len(points), 4))
rgba[:, 0] = red
rgba[:, 1] = green
rgba[:, 2] = blue
rgba[:, 3] = alpha
return rgba

def show_plot_for_detectors(self, detector_indices: Iterable[int]) -> None:
"""Show line plot for specified detectors"""
self._view.show_plot_for_detectors(
self._model.workspace(), [self._model.workspace_index_from_detector_index(d) for d in detector_indices]
)

def show_info_text_for_detectors(self, detector_indices: Iterable[int]) -> None:
"""Show text information for specified detectors"""
detector_infos = [self._model.get_detector_info_text(d) for d in detector_indices]
self._view.update_selected_detector_info(detector_infos)
284 changes: 284 additions & 0 deletions qt/python/instrumentview/instrumentview/FullInstrumentViewWindow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
# Mantid Repository : https://github.com/mantidproject/mantid
#
# Copyright &copy; 2025 ISIS Rutherford Appleton Laboratory UKRI,
# NScD Oak Ridge National Laboratory, European Spallation Source,
# Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS
# SPDX - License - Identifier: GPL - 3.0 +
from qtpy.QtWidgets import (
QMainWindow,
QVBoxLayout,
QHBoxLayout,
QWidget,
QLabel,
QLineEdit,
QGroupBox,
QSizePolicy,
QComboBox,
QSplitter,
QCheckBox,
QTextEdit,
)
from qtpy.QtGui import QPalette, QIntValidator
from qtpy.QtCore import Qt
from pyvistaqt import BackgroundPlotter
import matplotlib.pyplot as plt
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from instrumentview.FullInstrumentViewPresenter import FullInstrumentViewPresenter
from instrumentview.DetectorInfo import DetectorInfo
from typing import Callable
import numpy as np


class FullInstrumentViewWindow(QMainWindow):
"""View for the Instrument View window. Contains the 3D view, the projection view, boxes showing information about the selected
detector, and a line plot of selected detector(s)"""

_detector_spectrum_fig = None

def __init__(self, workspace, parent=None, off_screen=False):
"""The instrument in the given workspace will be displayed. The off_screen option is for testing or rendering an image
e.g. in a script."""
super(FullInstrumentViewWindow, self).__init__(parent)
self.setWindowTitle("Instrument View")

central_widget = QWidget(self)
self.setCentralWidget(central_widget)
parent_horizontal_layout = QHBoxLayout(central_widget)
pyvista_vertical_layout = QVBoxLayout()
options_vertical_layout = QVBoxLayout()
parent_horizontal_layout.addLayout(options_vertical_layout, 1)
parent_horizontal_layout.addLayout(pyvista_vertical_layout, 3)

self.main_plotter = BackgroundPlotter(show=False, menu_bar=False, toolbar=False, off_screen=off_screen)
pyvista_vertical_layout.addWidget(self.main_plotter.app_window)
self.projection_plotter = BackgroundPlotter(show=False, menu_bar=False, toolbar=False, off_screen=off_screen)
pyvista_vertical_layout.addWidget(self.projection_plotter.app_window)

detector_group_box = QGroupBox("Detector Info")
detector_vbox = QVBoxLayout()
self._detector_name_edit = self._add_detector_info_boxes(detector_vbox, "Name")
self._detector_id_edit = self._add_detector_info_boxes(detector_vbox, "Detector ID")
self._detector_workspace_index_edit = self._add_detector_info_boxes(detector_vbox, "Workspace Index")
self._detector_component_path_edit = self._add_detector_info_boxes(detector_vbox, "Component Path")
self._detector_xyz_edit = self._add_detector_info_boxes(detector_vbox, "XYZ Position")
self._detector_spherical_position_edit = self._add_detector_info_boxes(detector_vbox, "Spherical Position")
self._detector_pixel_counts_edit = self._add_detector_info_boxes(detector_vbox, "Pixel Counts")
detector_group_box.setLayout(detector_vbox)

time_of_flight_group_box = QGroupBox("Time of Flight")
self._tof_min_edit, self._tof_max_edit = self._add_min_max_group_box(
time_of_flight_group_box, self._on_tof_limits_updated, self._on_tof_limits_updated
)
contour_range_group_box = QGroupBox("Contour Range")
self._contour_range_min_edit, self._contour_range_max_edit = self._add_min_max_group_box(
contour_range_group_box, self._on_contour_limits_updated, self._on_contour_limits_updated
)

multi_select_group_box = QGroupBox("Multi-Select")
multi_select_h_layout = QHBoxLayout()
self._multi_Select_Check = QCheckBox()
self._multi_Select_Check.setText("Select multiple detectors")
self._multi_Select_Check.stateChanged.connect(self._on_multi_select_check_box_clicked)
multi_select_h_layout.addWidget(self._multi_Select_Check)
multi_select_group_box.setLayout(multi_select_h_layout)

options_vertical_layout.addWidget(detector_group_box)
options_vertical_layout.addWidget(time_of_flight_group_box)
options_vertical_layout.addWidget(contour_range_group_box)
options_vertical_layout.addWidget(multi_select_group_box)

self._presenter = FullInstrumentViewPresenter(self, workspace)

projection_group_box = QGroupBox("Projection")
projection_vbox = QVBoxLayout()
self._setup_projection_options(projection_vbox)
projection_group_box.setLayout(projection_vbox)
options_vertical_layout.addWidget(projection_group_box)

options_vertical_layout.addWidget(QSplitter(Qt.Horizontal))
self._detector_spectrum_fig, self._detector_spectrum_axes = plt.subplots(subplot_kw={"projection": "mantid"})
self._detector_figure_canvas = FigureCanvas(self._detector_spectrum_fig)
options_vertical_layout.addWidget(self._detector_figure_canvas)

options_vertical_layout.addStretch()
central_widget.setLayout(parent_horizontal_layout)
self.resize(1300, 1000)
self.main_plotter.reset_camera()
self.projection_plotter.reset_camera()

def _add_min_max_group_box(self, parent_box: QGroupBox, min_callback, max_callback) -> tuple[QLineEdit, QLineEdit]:
"""Creates a minimum and a maximum box (with labels) inside the given group box. The callbacks will be attached to textEdited
signal of the boxes"""
root_vbox = QVBoxLayout()
min_hbox = QHBoxLayout()
min_hbox.addWidget(QLabel("Min"))
min_edit = QLineEdit()
min_edit.textEdited.connect(min_callback)
max_int_32 = np.iinfo(np.int32).max
min_edit.setValidator(QIntValidator(0, max_int_32, self))
min_hbox.addWidget(min_edit)
max_hbox = QHBoxLayout()
max_hbox.addWidget(QLabel("Max"))
max_edit = QLineEdit()
max_edit.textEdited.connect(max_callback)
max_edit.setValidator(QIntValidator(0, max_int_32, self))
max_hbox.addWidget(max_edit)
root_vbox.addLayout(min_hbox)
root_vbox.addLayout(max_hbox)
parent_box.setLayout(root_vbox)
return (min_edit, max_edit)

def _add_detector_info_boxes(self, parent_box: QVBoxLayout, label: str) -> QHBoxLayout:
"""Adds a text box to the given parent that is designed to show read-only information about the selected detector"""
hbox = QHBoxLayout()
hbox.addWidget(QLabel(label))
line_edit = QTextEdit()
line_edit.document().setTextWidth(line_edit.viewport().width())
line_edit.setFixedHeight(27)
line_edit.setReadOnly(True)
line_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
hbox.addWidget(line_edit)
parent_box.addLayout(hbox)
palette = line_edit.palette()
palette.setColor(QPalette.Base, palette.color(QPalette.Window))
line_edit.setPalette(palette)
return line_edit

def _setup_projection_options(self, parent: QVBoxLayout):
"""Add widgets for the projection options"""
projection_combo_box = QComboBox(self)
projection_combo_box.addItems(self._presenter.projection_combo_options())
projection_combo_box.currentIndexChanged.connect(self._on_projection_combo_box_changed)
parent.addWidget(projection_combo_box)

def _on_projection_combo_box_changed(self, value):
"""If the projection type is changed, then tell the presenter to do something"""
if type(value) is int:
self._presenter.projection_option_selected(value)

def _on_multi_select_check_box_clicked(self, state):
"""Tell the presenter if either single or multi select is enabled"""
self._presenter.set_multi_select_enabled(state == 2)

def set_contour_range_limits(self, contour_limits: list) -> None:
"""Update the contour range edit boxes with formatted text"""
self._contour_range_min_edit.setText(f"{contour_limits[0]:.0f}")
self._contour_range_max_edit.setText(f"{contour_limits[1]:.0f}")

def set_tof_range_limits(self, tof_limits: list) -> None:
"""Update the TOF edit boxes with formatted text"""
self._tof_min_edit.setText(f"{tof_limits[0]:.0f}")
self._tof_max_edit.setText(f"{tof_limits[1]:.0f}")

def _on_tof_limits_updated(self, text):
"""When TOF limits are changed, read the new limits and tell the presenter to update the colours accordingly"""
is_valid, min, max = self._parse_min_max_text(text, self._tof_min_edit, self._tof_max_edit)
if is_valid:
self._presenter.set_tof_limits(min, max)

def _on_contour_limits_updated(self, text):
"""When contour limits are changed, read the new limits and tell the presenter to update the colours accordingly"""
is_valid, min, max = self._parse_min_max_text(text, self._contour_range_min_edit, self._contour_range_max_edit)
if is_valid:
self._presenter.set_contour_limits(min, max)

def _parse_min_max_text(self, text, min_edit, max_edit) -> tuple[bool, int, int]:
"""Try to parse the text in the edit boxes as numbers. Return the results and whether the attempt was successful."""
if text is None:
return (False, 0, 0)
try:
min = int(min_edit.text())
max = int(max_edit.text())
except ValueError:
return (False, 0, 0)
if max <= min:
return (False, min, max)
return (True, min, max)

def update_scalar_range(self, clim, label: str) -> None:
"""Set the range of the colours displayed, i.e. the legend"""
self.main_plotter.update_scalar_bar_range(clim, label)
self.projection_plotter.update_scalar_bar_range(clim, label)

def closeEvent(self, QCloseEvent):
"""When closing, make sure to close the plotters and figure correctly to prevent errors"""
super().closeEvent(QCloseEvent)
self.main_plotter.close()
self.projection_plotter.close()
if self._detector_spectrum_fig is not None:
plt.close(self._detector_spectrum_fig.get_label())

def add_simple_shape(self, mesh, colour=None, pickable=False):
"""Draw the given mesh in the main plotter window"""
self.main_plotter.add_mesh(mesh, color=colour, pickable=pickable)

def add_mesh(self, mesh, pickable=False, scalars=None, clim=None) -> None:
"""Draw the given mesh in the main plotter window"""
self.main_plotter.add_mesh(mesh, pickable=pickable, scalars=scalars, clim=clim, render_points_as_spheres=True, point_size=7)

def add_rgba_mesh(self, mesh, scalars):
"""Draw the given mesh in the main plotter window, and set the colours manually with RGBA numbers"""
self.main_plotter.add_mesh(mesh, scalars=scalars, rgba=True, render_points_as_spheres=True, point_size=10)

def enable_point_picking(self, callback=None) -> None:
"""Switch on point picking, i.e. picking a single point with right-click"""
self.main_plotter.disable_picking()
if not self.main_plotter.off_screen:
self.main_plotter.enable_point_picking(show_message=False, callback=callback, use_picker=callback is not None)

def enable_rectangle_picking(self, callback=None) -> None:
"""Switch on rectangle picking, i.e. draw a rectangle to select all detectors within the rectangle"""
self.main_plotter.disable_picking()
if not self.main_plotter.off_screen:
self.main_plotter.enable_rectangle_picking(callback=callback, use_picker=callback is not None, font_size=12)

def add_projection_mesh(self, mesh, scalars=None, clim=None) -> None:
"""Draw the given mesh in the projection plotter. This is a 2D plot so we set options accordingly on the plotter"""
self.projection_plotter.clear()
self.projection_plotter.add_mesh(mesh, scalars=scalars, clim=clim, render_points_as_spheres=True, point_size=7)
self.projection_plotter.view_xy()
if not self.projection_plotter.off_screen:
self.projection_plotter.enable_image_style()

def show_axes(self) -> None:
"""Show axes on the main plotter"""
if not self.main_plotter.off_screen:
self.main_plotter.show_axes()

def set_camera_focal_point(self, focal_point) -> None:
"""Set the camera focal point on the main plotter"""
self.main_plotter.camera.focal_point = focal_point

def show_plot_for_detectors(self, workspace, workspace_indices: list) -> None:
"""Plot all the given spectra, where they are defined by their workspace indices, not the spectra numbers"""
self._detector_spectrum_axes.clear()

for d in workspace_indices:
self._detector_spectrum_axes.plot(workspace, label=workspace.name() + "Workspace Index " + str(d), wkspIndex=d)

self._detector_spectrum_axes.legend(fontsize=8.0).set_draggable(True)
self._detector_figure_canvas.draw()

def update_selected_detector_info(self, detector_infos: list[DetectorInfo]) -> None:
"""For a list of detectors, with their info wrapped up in a class, update all of the info text boxes"""
self._set_detector_edit_text(self._detector_name_edit, detector_infos, lambda d: d.name)
self._set_detector_edit_text(self._detector_id_edit, detector_infos, lambda d: str(d.detector_id))
self._set_detector_edit_text(self._detector_workspace_index_edit, detector_infos, lambda d: str(d.workspace_index))
self._set_detector_edit_text(self._detector_component_path_edit, detector_infos, lambda d: d.component_path)
self._set_detector_edit_text(
self._detector_xyz_edit,
detector_infos,
lambda d: f"x: {d.xyz_position[0]:.3f}, y: {d.xyz_position[1]:.3f}, z: {d.xyz_position[2]:.3f}",
)
self._set_detector_edit_text(
self._detector_spherical_position_edit,
detector_infos,
lambda d: f"r: {d.spherical_position[0]:.3f}, t: {d.spherical_position[1]:.1f}, p: {d.spherical_position[2]:.1f}",
)
self._set_detector_edit_text(self._detector_pixel_counts_edit, detector_infos, lambda d: str(d.pixel_counts))

def _set_detector_edit_text(
self, edit_box: QTextEdit, detector_infos: list[DetectorInfo], property_lambda: Callable[[DetectorInfo], str]
) -> None:
"""Set the text in one of the detector info boxes"""
edit_box.setPlainText(",".join(property_lambda(d) for d in detector_infos))
30 changes: 30 additions & 0 deletions qt/python/instrumentview/instrumentview/InstrumentView.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Mantid Repository : https://github.com/mantidproject/mantid
#
# Copyright &copy; 2025 ISIS Rutherford Appleton Laboratory UKRI,
# NScD Oak Ridge National Laboratory, European Spallation Source,
# Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS
# SPDX - License - Identifier: GPL - 3.0 +
from instrumentview.FullInstrumentViewWindow import FullInstrumentViewWindow
from mantid.simpleapi import Load
from pathlib import Path
from qtpy.QtWidgets import QApplication
from qtpy.QtGui import QIcon
import sys
import os


class InstrumentView:
"""Show the Instrument View in a separate window"""

def main(file_path: Path):
sys.exit(InstrumentView.start_app_open_window(file_path))

def start_app_open_window(file_path: Path):
"""Load the given file, then open the Instrument View in a separate window with that workspace displayed"""
app = QApplication(sys.argv)
ws = Load(str(file_path), StoreInADS=False)
window = FullInstrumentViewWindow(ws)
current_dir = os.path.dirname(__file__)
app.setWindowIcon(QIcon(f"{current_dir}/mantidplot.png"))
window.show()
app.exec_()
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Mantid Repository : https://github.com/mantidproject/mantid
#
# Copyright &copy; 2025 ISIS Rutherford Appleton Laboratory UKRI,
# NScD Oak Ridge National Laboratory, European Spallation Source,
# Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS
# SPDX - License - Identifier: GPL - 3.0 +
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Mantid Repository : https://github.com/mantidproject/mantid
#
# Copyright &copy; 2025 ISIS Rutherford Appleton Laboratory UKRI,
# NScD Oak Ridge National Laboratory, European Spallation Source,
# Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS
# SPDX - License - Identifier: GPL - 3.0 +
from instrumentview.Projections.projection import projection
import numpy as np


class cylindrical_projection(projection):
"""2D projection with a cylindrical coordinate system, see https://en.wikipedia.org/wiki/Cylindrical_coordinate_system"""

def __init__(self, workspace, detector_indices, axis: np.ndarray):
super().__init__(workspace, detector_indices, axis)

def _calculate_2d_coordinates(self, detector_index: int) -> tuple[float, float]:
detector_relative_position = np.array(self._component_info.position(detector_index)) - self._sample_position
z = detector_relative_position.dot(self._projection_axis)
x = detector_relative_position.dot(self._x_axis)
y = detector_relative_position.dot(self._y_axis)

# u_scale = 1. / np.sqrt(x * x + y * y)
v_scale = 1.0 / np.sqrt(x * x + y * y + z * z)

v = z * v_scale
u = -np.atan2(y, x) # * u_scale

# use equal area cylindrical projection with v = sin(latitude), u = longitude
return (u, v)
127 changes: 127 additions & 0 deletions qt/python/instrumentview/instrumentview/Projections/projection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Mantid Repository : https://github.com/mantidproject/mantid
#
# Copyright &copy; 2025 ISIS Rutherford Appleton Laboratory UKRI,
# NScD Oak Ridge National Laboratory, European Spallation Source,
# Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS
# SPDX - License - Identifier: GPL - 3.0 +
from abc import ABC, abstractmethod
import numpy as np


class projection(ABC):
"""Base class for calculating a 2D projection with a specified axis"""

_projection_axis = None
_x_axis = None
_y_axis = None
_u_period = np.pi
_detector_x_coordinates = None
_detector_y_coordinates = None
_x_range = None
_y_range = None

def __init__(self, workspace, detector_indices, axis: np.ndarray):
"""For the given workspace and detectors, calculate 2D points with specified projection axis"""
self._component_info = workspace.componentInfo()
self._sample_position = np.array(self._component_info.samplePosition())
self._detector_indices = detector_indices
self._projection_axis = np.array(axis)
self._calculate_axes()
self._calculate_detector_coordinates()
self._find_and_correct_x_gap()

def _calculate_axes(self):
"""The projection axis is specified, we calculate a 3D coordinate system based on that"""
position = np.array(self._component_info.position(0))
z = position.dot(self._projection_axis)
if z == 0 or np.abs(z) == np.linalg.norm(position):
# Find the shortest projection of the projection axis and direct the x axis along it
if np.abs(self._projection_axis[2]) < np.abs(self._projection_axis[1]):
self._x_axis = np.array([0, 0, 1])
elif np.abs(self._projection_axis[1]) < np.abs(self._projection_axis[0]):
self._x_axis = np.array([0, 1, 0])
else:
self._x_axis = np.array([1, 0, 0])
else:
x_axis = position - z * self._projection_axis
self._x_axis = x_axis / np.linalg.norm(x_axis)

self._y_axis = np.cross(self._projection_axis, self._x_axis)

@abstractmethod
def _calculate_2d_coordinates(self, detector_index: int) -> tuple[float, float]:
pass

def _calculate_detector_coordinates(self):
"""Calculate 2D projection coordinates and store data"""
x_values = []
y_values = []
for det_id in self._detector_indices:
x, y = self._calculate_2d_coordinates(det_id)
x_values.append(x)
y_values.append(y)
self._detector_x_coordinates = np.array(x_values)
self._detector_y_coordinates = np.array(y_values)
self._x_range = (self._detector_x_coordinates.min(), self._detector_x_coordinates.max())
self._y_range = (self._detector_y_coordinates.min(), self._detector_y_coordinates.max())

def _find_and_correct_x_gap(self):
"""Shift points based on the specified period so that they appear within the correct x range when plotted"""
if self._u_period == 0:
return

number_of_bins = 1000
x_bins = [False for i in range(number_of_bins)]
bin_width = np.abs(self._x_range[1] - self._x_range[0]) / (number_of_bins - 1)
if bin_width == 0.0:
return

for i in range(len(self._detector_indices)):
if not self._component_info.hasValidShape(self._detector_indices[i]):
continue
x = self._detector_x_coordinates[i]
bin_i = int((x - self._x_range[0]) / bin_width)
x_bins[bin_i] = True

i_from = 0
i_to = 0
i0 = 0
in_gap = False

for i in range(number_of_bins):
if not x_bins[i]:
if not in_gap:
i0 = i
in_gap = True
else:
if in_gap and i_to - i_from < i - i0:
i_from = i0 # First bin in the gap
i_to = i # First bin after the gap
in_gap = False

x_from = self._x_range[0] + i_from * bin_width
x_to = self._x_range[0] + i_to * bin_width
if x_to - x_from > self._u_period - (self._x_range[1] - self._x_range[0]):
self._x_range = (x_to, x_from)
if self._x_range[0] > self._x_range[1]:
self._x_range = (self._x_range[0], self._x_range[1] + self._u_period)
for i in range(len(self._detector_indices)):
if not self._component_info.hasValidShape(self._detector_indices[i]):
continue
self._apply_x_correction(i)

def _apply_x_correction(self, i: int) -> None:
"""Set x coordinate of specified point to be within the correct range, with the period used as the modulus"""
x = self._detector_x_coordinates[i]
if self._u_period == 0:
return
if x < self._x_range[0]:
periods = np.floor((self._x_range[1] - x) / self._u_period) * self._u_period
x = x + periods
if x > self._x_range[1]:
periods = np.floor((x - self._x_range[0]) / self._u_period) * self._u_period
x = x - periods
self._detector_x_coordinates[i] = x

def coordinate_for_detector(self, detector_index: int) -> tuple[float, float]:
return (self._detector_x_coordinates[detector_index], self._detector_y_coordinates[detector_index])
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Mantid Repository : https://github.com/mantidproject/mantid
#
# Copyright &copy; 2025 ISIS Rutherford Appleton Laboratory UKRI,
# NScD Oak Ridge National Laboratory, European Spallation Source,
# Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS
# SPDX - License - Identifier: GPL - 3.0 +
from instrumentview.Projections.projection import projection
import numpy as np


class spherical_projection(projection):
"""2D projection with a spherical coordinate system, see https://en.wikipedia.org/wiki/Spherical_coordinate_system"""

def __init__(self, workspace, detector_indices, axis: np.ndarray):
super().__init__(workspace, detector_indices, axis)

def _calculate_2d_coordinates(self, detector_index: int) -> tuple[float, float]:
detector_relative_position = np.array(self._component_info.position(detector_index)) - self._sample_position
v = detector_relative_position.dot(self._projection_axis)
x = detector_relative_position.dot(self._x_axis)
y = detector_relative_position.dot(self._y_axis)

r = np.sqrt(x * x + y * y + v * v)
# u_scale = 1. / np.sqrt(x * x + y * y)
# v_scale = 1. / r

u = -np.atan2(y, x) # * u_scale
v = -np.acos(v / r) # * v_scale
return (u, v)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Mantid Repository : https://github.com/mantidproject/mantid
#
# Copyright &copy; 2025 ISIS Rutherford Appleton Laboratory UKRI,
# NScD Oak Ridge National Laboratory, European Spallation Source,
# Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS
# SPDX - License - Identifier: GPL - 3.0 +
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Mantid Repository : https://github.com/mantidproject/mantid
#
# Copyright &copy; 2025 ISIS Rutherford Appleton Laboratory UKRI,
# NScD Oak Ridge National Laboratory, European Spallation Source,
# Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS
# SPDX - License - Identifier: GPL - 3.0 +
import unittest.mock
from instrumentview.Projections.cylindrical_projection import cylindrical_projection
import unittest
from unittest.mock import MagicMock
import numpy as np


class TestCylindricalProjection(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.mock_workspace = MagicMock()
cls.mock_componentInfo = MagicMock()
cls.mock_componentInfo.samplePosition.return_value = np.array([0, 0, 0])
cls.mock_componentInfo.position = cls.mock_position
cls.mock_componentInfo.hasValidShape.return_value = True
cls.mock_workspace.componentInfo.return_value = cls.mock_componentInfo
cls.detector_indices = [0, 1, 2]
cls.abs_tol = 1e-9

def mock_position(index: int):
if index == 0:
return [0, 1, 0]
if index == 1:
return [3, 1, 0]
if index == 2:
return [-3, 1, 0]

raise ValueError(f"Unexpected component index: {index}")

def test_calculate_2d_coordinates_x(self):
# x-axis projection
# No need to mock out the x adjustments because in this case there isn't any need for
# the projection class to call it.
self._run_test(
projection_axis=[1, 0, 0],
expected_x_axis=[0, 1, 0],
expected_y_axis=[0, 0, 1],
expected_projections=[(0, 0), (0, 3 / np.sqrt(10)), (0, -3 / np.sqrt(10))],
)

@unittest.mock.patch("instrumentview.Projections.cylindrical_projection.cylindrical_projection._apply_x_correction")
def test_calculate_2d_coordinates_y(self, mock_apply_x_correction):
# y-axis projection
self._run_test(
projection_axis=[0, 1, 0],
expected_x_axis=[0, 0, 1],
expected_y_axis=[1, 0, 0],
expected_projections=[(0, 1), (-np.pi / 2, 1 / np.sqrt(10)), (np.pi / 2, 1 / np.sqrt(10))],
mock_apply_x_correction=mock_apply_x_correction,
)

@unittest.mock.patch("instrumentview.Projections.cylindrical_projection.cylindrical_projection._apply_x_correction")
def test_calculate_2d_coordinates_z(self, mock_apply_x_correction):
# z-axis projection
self._run_test(
projection_axis=[0, 0, 1],
expected_x_axis=[1, 0, 0],
expected_y_axis=[0, 1, 0],
expected_projections=[(-np.pi / 2, 0), (-np.atan2(1, 3), 0), (-np.atan2(1, -3), 0)],
mock_apply_x_correction=mock_apply_x_correction,
)

def _run_test(self, projection_axis, expected_x_axis, expected_y_axis, expected_projections, mock_apply_x_correction=None):
cylinder = cylindrical_projection(self.mock_workspace, self.detector_indices, projection_axis)
np.testing.assert_allclose(cylinder._projection_axis, projection_axis, atol=self.abs_tol)
np.testing.assert_allclose(cylinder._x_axis, expected_x_axis, atol=self.abs_tol)
np.testing.assert_allclose(cylinder._y_axis, expected_y_axis, atol=self.abs_tol)
for i in self.detector_indices:
expected = expected_projections[i]
calculated = cylinder.coordinate_for_detector(i)
np.testing.assert_allclose(calculated, expected, atol=self.abs_tol)
if mock_apply_x_correction is not None:
self.assertEqual(mock_apply_x_correction.call_count, len(self.detector_indices))
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Mantid Repository : https://github.com/mantidproject/mantid
#
# Copyright &copy; 2025 ISIS Rutherford Appleton Laboratory UKRI,
# NScD Oak Ridge National Laboratory, European Spallation Source,
# Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS
# SPDX - License - Identifier: GPL - 3.0 +

import unittest.mock
from instrumentview.Projections.cylindrical_projection import cylindrical_projection

import numpy as np
import unittest
from unittest.mock import MagicMock


class TestProjection(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.mock_workspace = MagicMock()
cls.mock_componentInfo = MagicMock()
cls.mock_componentInfo.samplePosition.return_value = np.array([0, 0, 0])
cls.mock_componentInfo.position = cls.mock_position
cls.mock_componentInfo.hasValidShape.return_value = True
cls.mock_workspace.componentInfo.return_value = cls.mock_componentInfo
cls.detector_indices = [0, 1, 2]

def mock_position(index: int):
if index == 0:
return [0, 1, 0]
if index == 1:
return [2, 1, 0]
if index == 2:
return [-2, 1, 0]

raise ValueError(f"Unexpected component index: {index}")

def test_apply_x_correction_below_min(self):
proj = cylindrical_projection(self.mock_workspace, self.detector_indices, [0, 1, 0])
x_min = proj._x_range[0]
x_max = proj._x_range[1]
proj._detector_x_coordinates[0] = x_min - np.pi / 2
self.assertLess(proj._detector_x_coordinates[0], x_min)
proj._apply_x_correction(0)
self.assertGreaterEqual(proj._detector_x_coordinates[0], x_min)
self.assertLessEqual(proj._detector_x_coordinates[0], x_max)

def test_apply_x_correction_above_max(self):
proj = cylindrical_projection(self.mock_workspace, self.detector_indices, [0, 1, 0])
x_min = proj._x_range[0]
x_max = proj._x_range[1]
proj._detector_x_coordinates[0] = x_max + np.pi / 2
self.assertGreater(proj._detector_x_coordinates[0], x_max)
proj._apply_x_correction(0)
self.assertGreaterEqual(proj._detector_x_coordinates[0], x_min)
self.assertLessEqual(proj._detector_x_coordinates[0], x_max)

@unittest.mock.patch("instrumentview.Projections.projection.projection._apply_x_correction")
def test_find_and_correct_x_gap(self, mock_apply_x_correction):
proj = cylindrical_projection(self.mock_workspace, self.detector_indices, [0, 0, 1])
proj._u_period = (proj._x_range[1] - proj._x_range[0]) / 2
mock_apply_x_correction.reset_mock()
proj._find_and_correct_x_gap()
self.assertEqual(len(self.detector_indices), mock_apply_x_correction.call_count)
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Mantid Repository : https://github.com/mantidproject/mantid
#
# Copyright &copy; 2025 ISIS Rutherford Appleton Laboratory UKRI,
# NScD Oak Ridge National Laboratory, European Spallation Source,
# Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS
# SPDX - License - Identifier: GPL - 3.0 +
import unittest.mock
from instrumentview.Projections.spherical_projection import spherical_projection
import unittest
from unittest.mock import MagicMock
import numpy as np


class TestSphericalProjection(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.mock_workspace = MagicMock()
cls.mock_componentInfo = MagicMock()
cls.mock_componentInfo.samplePosition.return_value = np.array([0, 0, 0])
cls.radius = 2.0
cls.mock_componentInfo.position = lambda x: cls.mock_position(x, cls.radius)
cls.mock_componentInfo.hasValidShape.return_value = True
cls.mock_workspace.componentInfo.return_value = cls.mock_componentInfo
cls.detector_indices = [0, 1, 2]
cls.abs_tol = 1e-9

def mock_position(index: int, radius: float):
# All points on a sphere
if index == 0:
return [0, radius, 0]
if index == 1:
return [0, 0, radius]
if index == 2:
return [radius, 0, 0]

raise ValueError(f"Unexpected component index: {index}")

def test_calculate_2d_coordinates_x(self):
# x-axis projection
self._run_test(
projection_axis=[1, 0, 0],
expected_x_axis=[0, 1, 0],
expected_y_axis=[0, 0, 1],
expected_projections=[(0, -np.pi / 2), (-np.pi / 2, -np.pi / 2), (0, 0)],
)

def test_calculate_2d_coordinates_y(self):
# y-axis projection
self._run_test(
projection_axis=[0, 1, 0],
expected_x_axis=[0, 0, 1],
expected_y_axis=[1, 0, 0],
expected_projections=[(0, 0), (0, -np.pi / 2), (-np.pi / 2, -np.pi / 2)],
)

def test_calculate_2d_coordinates_z(self):
# z-axis projection
self._run_test(
projection_axis=[0, 0, 1],
expected_x_axis=[1, 0, 0],
expected_y_axis=[0, 1, 0],
expected_projections=[(-np.pi / 2, -np.pi / 2), (0, 0), (0, -np.pi / 2)],
)

def _run_test(self, projection_axis, expected_x_axis, expected_y_axis, expected_projections):
sphere = spherical_projection(self.mock_workspace, self.detector_indices, projection_axis)
np.testing.assert_allclose(sphere._projection_axis, projection_axis, atol=self.abs_tol)
np.testing.assert_allclose(sphere._x_axis, expected_x_axis, atol=self.abs_tol)
np.testing.assert_allclose(sphere._y_axis, expected_y_axis, atol=self.abs_tol)
for i in self.detector_indices:
expected = expected_projections[i]
calculated = sphere.coordinate_for_detector(i)
np.testing.assert_allclose(calculated, expected, atol=self.abs_tol)
6 changes: 6 additions & 0 deletions qt/python/instrumentview/instrumentview/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Mantid Repository : https://github.com/mantidproject/mantid
#
# Copyright &copy; 2025 ISIS Rutherford Appleton Laboratory UKRI,
# NScD Oak Ridge National Laboratory, European Spallation Source,
# Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS
# SPDX - License - Identifier: GPL - 3.0 +
15 changes: 15 additions & 0 deletions qt/python/instrumentview/instrumentview/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Mantid Repository : https://github.com/mantidproject/mantid
#
# Copyright &copy; 2025 ISIS Rutherford Appleton Laboratory UKRI,
# NScD Oak Ridge National Laboratory, European Spallation Source,
# Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS
# SPDX - License - Identifier: GPL - 3.0 +
from instrumentview.InstrumentView import InstrumentView
import argparse
from pathlib import Path

if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Displays the 3D view of an instrument, given a file.")
parser.add_argument("--file", help="File path", type=str, required=True)
args = parser.parse_args()
InstrumentView.main(Path(args.file))
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions qt/python/instrumentview/instrumentview/test/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Mantid Repository : https://github.com/mantidproject/mantid
#
# Copyright &copy; 2025 ISIS Rutherford Appleton Laboratory UKRI,
# NScD Oak Ridge National Laboratory, European Spallation Source,
# Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS
# SPDX - License - Identifier: GPL - 3.0 +
121 changes: 121 additions & 0 deletions qt/python/instrumentview/instrumentview/test/test_instruments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Copyright &copy; 2025 ISIS Rutherford Appleton Laboratory UKRI,
# NScD Oak Ridge National Laboratory, European Spallation Source,
# Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS
# SPDX - License - Identifier: GPL - 3.0 +
from mantid.simpleapi import LoadEmptyInstrument
from instrumentview.FullInstrumentViewWindow import FullInstrumentViewWindow
import unittest
from qtpy.QtWidgets import QApplication


class TestEmptyInstruments(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls._app = QApplication([])

@classmethod
def tearDownClass(cls):
cls._app.quit()

def _create_empty_instrument_and_draw(self, instrument: str):
ws = LoadEmptyInstrument(InstrumentName=instrument)
FullInstrumentViewWindow(ws, off_screen=True)

def test_gem(self):
self._create_empty_instrument_and_draw("GEM")

def test_mari(self):
self._create_empty_instrument_and_draw("Mari")

def test_merlin(self):
self._create_empty_instrument_and_draw("Merlin")

def test_vesuvio(self):
self._create_empty_instrument_and_draw("Vesuvio")

def test_maps(self):
self._create_empty_instrument_and_draw("Maps")

def test_sandals(self):
self._create_empty_instrument_and_draw("Sandals")

def test_alf(self):
self._create_empty_instrument_and_draw("Alf")

def test_surf(self):
self._create_empty_instrument_and_draw("Surf")

def test_crisp(self):
self._create_empty_instrument_and_draw("Crisp")

def test_loq(self):
self._create_empty_instrument_and_draw("LOQ")

def test_osiris(self):
self._create_empty_instrument_and_draw("Osiris")

def test_iris(self):
self._create_empty_instrument_and_draw("Iris")

def test_polaris(self):
self._create_empty_instrument_and_draw("Polaris")

def test_ines(self):
self._create_empty_instrument_and_draw("INES")

def test_tosca(self):
self._create_empty_instrument_and_draw("Tosca")

def test_larmor(self):
self._create_empty_instrument_and_draw("Larmor")

def test_offspec(self):
self._create_empty_instrument_and_draw("Offspec")

def test_inter(self):
self._create_empty_instrument_and_draw("Inter")

def test_polref(self):
self._create_empty_instrument_and_draw("Polref")

def test_sans2d(self):
self._create_empty_instrument_and_draw("Sans2d")

def test_let(self):
self._create_empty_instrument_and_draw("Let")

def test_nimrod(self):
self._create_empty_instrument_and_draw("Nimrod")

def test_hifi(self):
self._create_empty_instrument_and_draw("HiFi")

def test_musr(self):
self._create_empty_instrument_and_draw("MuSR")

def test_emu(self):
self._create_empty_instrument_and_draw("Emu")

def test_argus(self):
self._create_empty_instrument_and_draw("Argus")

def test_chronus(self):
self._create_empty_instrument_and_draw("Chronus")

def test_enginx(self):
self._create_empty_instrument_and_draw("Engin-X")

def test_hrpd(self):
self._create_empty_instrument_and_draw("HRPD")

def test_pearl(self):
self._create_empty_instrument_and_draw("Pearl")

def test_sxd(self):
self._create_empty_instrument_and_draw("SXD")

def test_wish(self):
self._create_empty_instrument_and_draw("WISH")

def test_zoom(self):
self._create_empty_instrument_and_draw("ZOOM")
129 changes: 129 additions & 0 deletions qt/python/instrumentview/instrumentview/test/test_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Copyright &copy; 2025 ISIS Rutherford Appleton Laboratory UKRI,
# NScD Oak Ridge National Laboratory, European Spallation Source,
# Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS
# SPDX - License - Identifier: GPL - 3.0 +
from mantid.simpleapi import CreateSampleWorkspace
from instrumentview.FullInstrumentViewModel import FullInstrumentViewModel
import unittest
from unittest import mock
import numpy as np


class TestFullInstrumentViewModel(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls._ws = CreateSampleWorkspace(OutputWorkspace="TestFullInstrumentViewModel", XUnit="TOF")

def setUp(self):
self._model = FullInstrumentViewModel(self._ws, False)

def test_union_with_current_bin_min_max(self):
current_min = self._model._bin_min
current_max = self._model._bin_max
self._model._union_with_current_bin_min_max(current_min - 1)
self.assertEqual(self._model._bin_min, current_min - 1)
self._model._union_with_current_bin_min_max(current_min)
self.assertEqual(self._model._bin_min, current_min - 1)
self.assertEqual(self._model._bin_max, current_max)
self._model._union_with_current_bin_min_max(current_max + 1)
self.assertEqual(self._model._bin_max, current_max + 1)

def test_update_time_of_flight_range(self):
integrated_spectra = list(range(len(self._ws.spectrumInfo())))
self._model._workspace = mock.MagicMock()
self._model._workspace.getIntegratedSpectra.return_value = integrated_spectra
self._model.update_time_of_flight_range(200, 10000, False)
self._model._workspace.getIntegratedSpectra.assert_called_once()
self.assertEqual(min(integrated_spectra), self._model._data_min)
self.assertEqual(max(integrated_spectra), self._model._data_max)

@mock.patch("pyvista.PolyData")
def test_draw_rectangular_bank(self, mock_polyData):
component_info = mock.MagicMock()
component_info.quadrilateralComponentCornerIndices.return_value = [0, 1, 2, 3]
component_info.position.return_value = [2, 2, 2]
component_info.scaleFactor.return_value = [1, 1, 1]
self._model.drawRectangularBank(component_info, 0)
mock_polyData.assert_called_once()
polyData_args = mock_polyData.call_args_list[0][0]
self.assertEqual((4, 3), polyData_args[0].shape)
self.assertTrue(polyData_args[0].all(where=lambda x: x == 2))
self.assertEqual([4, 0, 1, 2, 3], polyData_args[1])

@mock.patch("pyvista.Sphere")
def test_draw_single_detector_sphere(self, mock_sphere):
component_info = self._setup_draw_single_detector("SPHERE", [[0, 0, 0]], [10, 10])
self._model.drawSingleDetector(component_info, 0)
mock_sphere.assert_called_once()

@mock.patch("pyvista.UnstructuredGrid")
def test_draw_single_detector_cuboid(self, mock_unstructedGrid):
component_info = self._setup_draw_single_detector("CUBOID", [[0, 0, 0], [1, 1, 1], [2, 2, 2], [3, 3, 3]], [10, 10])
self._model.drawSingleDetector(component_info, 0)
mock_unstructedGrid.assert_called_once()

@mock.patch("pyvista.UnstructuredGrid")
def test_draw_single_detector_hexahedron(self, mock_unstructuredGrid):
component_info = self._setup_draw_single_detector("HEXAHEDRON", [[0, 0, 0], [1, 1, 1], [2, 2, 2], [3, 3, 3]] * 2, [])
self._model.drawSingleDetector(component_info, 0)
mock_unstructuredGrid.assert_called_once()

@mock.patch("pyvista.Cone")
def test_draw_single_detector_cone(self, mock_cone):
component_info = self._setup_draw_single_detector("CONE", [[0, 0, 0], [1, 1, 1]], [1, 10, 5])
self._model.drawSingleDetector(component_info, 0)
mock_cone.assert_called_once()

@mock.patch("pyvista.Cylinder")
def test_draw_single_detector_cylinder(self, mock_cylinder):
component_info = self._setup_draw_single_detector("CYLINDER", [[0, 0, 0], [1, 1, 1]], [1, 10, 5])
self._model.drawSingleDetector(component_info, 0)
mock_cylinder.assert_called_once()

@mock.patch("pyvista.PolyData")
def test_draw_single_detector_other(self, mock_polyData):
component_info = self._setup_draw_single_detector("unknown", [], [])
self._model.drawSingleDetector(component_info, 0)
mock_polyData.assert_called_once()

def _setup_draw_single_detector(self, shape: str, points: list[list[float]], dimensions: list[int]) -> mock.MagicMock:
component_info = mock.MagicMock()
component_info.position.return_value = [1, 1, 1]
mock_rotation = mock.MagicMock()
mock_rotation.getAngleAxis.return_value = [0, 0, 1, 0]
component_info.rotation.return_value = mock_rotation
component_info.scaleFactor.return_value = [1, 1, 1]
mock_shape = mock.MagicMock()
mock_shape.getGeometryShape.return_value = shape
mock_shape.getGeometryPoints.return_value = points
mock_shape.getGeometryDimensions.return_value = dimensions
mock_shape.getMesh.return_value = np.array(
[
[[0.025, 0.025, 0.02], [0.025, 0.025, 0.0], [-0.025, 0.025, 0.0]],
[[0.025, 0.025, 0.02], [-0.025, 0.025, 0.0], [-0.025, 0.025, 0.02]],
[[0.025, 0.025, 0.02], [-0.025, 0.025, 0.02], [-0.025, -0.025, 0.02]],
]
)
component_info.shape.return_value = mock_shape
return component_info

@mock.patch("instrumentview.FullInstrumentViewModel.DetectorInfo")
def test_get_detector_info_text(self, mock_detectorInfo):
self._model.get_detector_info_text(0)
mock_detectorInfo.assert_called_once()

@mock.patch("instrumentview.Projections.spherical_projection.spherical_projection")
def test_calculate_spherical_projection(self, mock_spherical_projection):
self._run_projection_test(mock_spherical_projection, True)

@mock.patch("instrumentview.Projections.cylindrical_projection.cylindrical_projection")
def test_calculate_cylindrical_projection(self, mock_cylindrical_projection):
self._run_projection_test(mock_cylindrical_projection, False)

def _run_projection_test(self, mock_projection_constructor, is_spherical):
mock_projection = mock.MagicMock()
mock_projection.coordinate_for_detector.return_value = (1, 2)
mock_projection_constructor.return_value = mock_projection
points = self._model.calculate_projection(is_spherical, axis=[0, 1, 0])
mock_projection_constructor.assert_called_once()
self.assertTrue(all(point == [1, 2, 0] for point in points))
108 changes: 108 additions & 0 deletions qt/python/instrumentview/instrumentview/test/test_presenter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Mantid Repository : https://github.com/mantidproject/mantid
#
# Copyright &copy; 2025 ISIS Rutherford Appleton Laboratory UKRI,
# NScD Oak Ridge National Laboratory, European Spallation Source,
# Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS
# SPDX - License - Identifier: GPL - 3.0 +
from mantid.simpleapi import CreateSampleWorkspace
from instrumentview.FullInstrumentViewPresenter import FullInstrumentViewPresenter
import unittest
from unittest import mock
from unittest.mock import MagicMock


class TestFullInstrumentViewPresenter(unittest.TestCase):
def setUp(self):
self._mock_view = MagicMock()
self._ws = CreateSampleWorkspace(OutputWorkspace="TestFullInstrumentViewPresenter")
self._presenter = FullInstrumentViewPresenter(self._mock_view, self._ws)
self._mock_view.reset_mock()

def tearDown(self):
self._ws.delete()

def test_projection_combo_options(self):
projections = self._presenter.projection_combo_options()
self.assertGreater(len(projections), 0)
self.assertTrue("Spherical X" in projections)

def test_projection_option_selected(self):
self._presenter.projection_option_selected(1)
self._mock_view.add_projection_mesh.assert_called_once()

@mock.patch("instrumentview.FullInstrumentViewPresenter.FullInstrumentViewPresenter.createPolyDataMesh")
@mock.patch("instrumentview.FullInstrumentViewModel.FullInstrumentViewModel.calculate_projection")
def test_projection_option_axis(self, mock_calculate_projection, mock_createPolyDataMesh):
for option_index in range(len(self._presenter.projection_combo_options())):
option = self._presenter.projection_combo_options()[option_index]
if option.endswith("X"):
axis = [1, 0, 0]
elif option.endswith("Y"):
axis = [0, 1, 0]
elif option.endswith("Z"):
axis = [0, 0, 1]
else:
return
self._presenter.projection_option_selected(option_index)
mock_calculate_projection.assert_called_once_with(option.startswith("Spherical"), axis)
mock_createPolyDataMesh.assert_called_once()
mock_calculate_projection.reset_mock()
mock_createPolyDataMesh.reset_mock()

def test_set_contour_limits(self):
self._presenter.set_contour_limits(0, 100)
self._mock_view.update_scalar_range.assert_called_once_with([0, 100], self._presenter._counts_label)

@mock.patch("instrumentview.FullInstrumentViewModel.FullInstrumentViewModel.update_time_of_flight_range")
@mock.patch("instrumentview.FullInstrumentViewPresenter.FullInstrumentViewPresenter.set_contour_limits")
def test_set_tof_limits(self, mock_set_contour_limits, mock_update_time_of_flight_range):
self._presenter.set_tof_limits(0, 100)
mock_update_time_of_flight_range.assert_called_once()
mock_set_contour_limits.assert_called_once()

@mock.patch("instrumentview.FullInstrumentViewPresenter.FullInstrumentViewPresenter.show_info_text_for_detectors")
@mock.patch("instrumentview.FullInstrumentViewPresenter.FullInstrumentViewPresenter.show_plot_for_detectors")
@mock.patch("instrumentview.FullInstrumentViewModel.FullInstrumentViewModel.detector_index")
def test_point_picked(self, mock_detector_index, mock_show_plot, mock_show_info_text):
mock_detector_index.return_value = 10
mock_picker = MagicMock()

def get_point_id():
return 1

mock_picker.GetPointId = get_point_id
self._presenter.point_picked(MagicMock(), mock_picker)
mock_show_plot.assert_called_once_with([10])
mock_show_info_text.assert_called_once_with([10])

def test_generate_single_colour(self):
green_vector = self._presenter.generateSingleColour([[1, 0, 0], [0, 1, 0]], 0, 1, 0, 0)
self.assertEqual(len(green_vector), 2)
self.assertTrue(green_vector.all(where=[0, 1, 0, 0]))

def test_show_plot_for_detectors(self):
mock_model = MagicMock()
self._presenter._model = mock_model

def mock_workspace_index(i):
return 2 * i

mock_model.workspace_index_from_detector_index = mock_workspace_index
self._presenter.show_plot_for_detectors([0, 1, 2])
self._mock_view.show_plot_for_detectors.assert_called_once_with(mock_model.workspace(), [0, 2, 4])

@mock.patch("instrumentview.FullInstrumentViewModel.FullInstrumentViewModel.get_detector_info_text")
def test_show_info_text_for_detectors(self, mock_get_detector_info_text):
mock_get_detector_info_text.return_value = "a"
self._presenter.show_info_text_for_detectors([0, 1, 2])
self._mock_view.update_selected_detector_info.assert_called_once_with(["a", "a", "a"])

def test_set_multi_select_enabled(self):
self._presenter.set_multi_select_enabled(True)
self._mock_view.enable_rectangle_picking.assert_called_once()
self._mock_view.enable_point_picking.assert_not_called()

def test_set_multi_select_disabled(self):
self._presenter.set_multi_select_enabled(False)
self._mock_view.enable_rectangle_picking.assert_not_called()
self._mock_view.enable_point_picking.assert_called_once()
3 changes: 3 additions & 0 deletions qt/python/instrumentview/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
19 changes: 19 additions & 0 deletions qt/python/instrumentview/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Mantid Repository : https://github.com/mantidproject/mantid
#
# Copyright &copy; 2024 ISIS Rutherford Appleton Laboratory UKRI,
# NScD Oak Ridge National Laboratory, European Spallation Source,
# Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS
# SPDX - License - Identifier: GPL - 3.0 +
import os
from setuptools import find_packages, setup

setup(
name="instrumentview",
install_requires=["mantidqt"],
version=os.environ["MANTID_VERSION_STR"],
entry_points={"gui_scripts": ["instrumentview = InstrumentView:main"]},
packages=find_packages(exclude=["*.test"]),
package_data={"": ["*.png"]},
include_package_data=True,
)
1 change: 1 addition & 0 deletions qt/python/mantidqt/mantidqt/_common.sip
Original file line number Diff line number Diff line change
@@ -423,6 +423,7 @@ signals:
void overplotMDHistoWithErrorsClicked(const QStringList &workspaceNames);
void sampleMaterialClicked(const QStringList &workspaceNames);
void sampleShapeClicked(const QStringList &workspaceNames);
void showNewInstrumentViewClicked(const QStringList &workspaceNames);
};

class AlgorithmHistoryWindow : public QDialog {
Original file line number Diff line number Diff line change
@@ -48,6 +48,7 @@ class EXPORT_OPT_MANTIDQT_COMMON WorkspaceTreeWidgetSimple : public WorkspaceTre
void sampleLogsClicked(const QStringList &workspaceName);
void sliceViewerClicked(const QStringList &workspaceName);
void showInstrumentClicked(const QStringList &workspaceNames);
void showNewInstrumentViewClicked(const QStringList &workspaceNames);
void showDataClicked(const QStringList &workspaceNames);
void showAlgorithmHistoryClicked(const QStringList &workspaceNames);
void showDetectorsClicked(const QStringList &workspaceNames);
@@ -82,6 +83,7 @@ private slots:
void onSampleLogsClicked();
void onSliceViewerClicked();
void onShowInstrumentClicked();
void onShowNewInstrumentViewClicked();
void onShowDataClicked();
void onShowAlgorithmHistoryClicked();
void onShowDetectorsClicked();
@@ -115,6 +117,6 @@ private slots:
*m_plotColorfill, *m_sampleLogs, *m_sliceViewer, *m_showInstrument, *m_showData, *m_showAlgorithmHistory,
*m_showDetectors, *m_plotAdvanced, *m_plotSurface, *m_plotWireframe, *m_plotContour, *m_plotMDHisto1D,
*m_overplotMDHisto1D, *m_plotMDHisto1DWithErrs, *m_overplotMDHisto1DWithErrs, *m_sampleMaterial, *m_sampleShape,
*m_superplot, *m_superplotWithErrs, *m_superplotBins, *m_superplotBinsWithErrs;
*m_superplot, *m_superplotWithErrs, *m_superplotBins, *m_superplotBinsWithErrs, *m_showNewInstrumentView;
};
} // namespace MantidQt::MantidWidgets
Original file line number Diff line number Diff line change
@@ -64,7 +64,8 @@ WorkspaceTreeWidgetSimple::WorkspaceTreeWidgetSimple(bool viewOnly, QWidget *par
m_sampleShape(new QAction("Show Sample Shape", this)), m_superplot(new QAction("Superplot...", this)),
m_superplotWithErrs(new QAction("Superplot with errors...", this)),
m_superplotBins(new QAction("Superplot bins...", this)),
m_superplotBinsWithErrs(new QAction("Superplot bins with errors...", this)) {
m_superplotBinsWithErrs(new QAction("Superplot bins with errors...", this)),
m_showNewInstrumentView(new QAction("Show Instrument (New)...", this)) {

// Replace the double click action on the MantidTreeWidget
m_tree->m_doubleClickAction = [&](const QString &wsName) { emit workspaceDoubleClicked(wsName); };
@@ -98,6 +99,7 @@ WorkspaceTreeWidgetSimple::WorkspaceTreeWidgetSimple(bool viewOnly, QWidget *par
connect(m_superplotWithErrs, SIGNAL(triggered()), this, SLOT(onSuperplotWithErrsClicked()));
connect(m_superplotBins, SIGNAL(triggered()), this, SLOT(onSuperplotBinsClicked()));
connect(m_superplotBinsWithErrs, SIGNAL(triggered()), this, SLOT(onSuperplotBinsWithErrsClicked()));
connect(m_showNewInstrumentView, SIGNAL(triggered()), this, SLOT(onShowNewInstrumentViewClicked()));
}

WorkspaceTreeWidgetSimple::~WorkspaceTreeWidgetSimple() = default;
@@ -224,6 +226,10 @@ void WorkspaceTreeWidgetSimple::onSuperplotBinsWithErrsClicked() {
emit superplotBinsWithErrsClicked(getSelectedWorkspaceNamesAsQList());
}

void WorkspaceTreeWidgetSimple::onShowNewInstrumentViewClicked() {
emit showNewInstrumentViewClicked(getSelectedWorkspaceNamesAsQList());
}

/**
* Create a new QMenu object filled with appropriate items for the given workspace
* The created object has this as its parent and WA_DeleteOnClose set
@@ -269,6 +275,9 @@ void WorkspaceTreeWidgetSimple::addMatrixWorkspaceActions(QMenu *menu, const Man
menu->addAction(m_sampleMaterial);
menu->addAction(m_sampleShape);
}
menu->addAction(m_showNewInstrumentView);
m_showNewInstrumentView->setEnabled(workspace.getInstrument() && !workspace.getInstrument()->getName().empty() &&
workspace.getAxis(1)->isSpectra());
}

void WorkspaceTreeWidgetSimple::addTableWorkspaceActions(QMenu *menu, const Mantid::API::ITableWorkspace &workspace) {