From fa2afe09a4d61a849486e82ad77219a6ca4f31f0 Mon Sep 17 00:00:00 2001 From: NatLeung96 Date: Wed, 17 Jun 2026 16:06:32 +0100 Subject: [PATCH 1/5] add interactive 3D volume plotting to msmapper --- mmg_toolbox/diffraction/msmapper.py | 184 ++++++++++++++++--- mmg_toolbox/diffraction/plotly_colourmaps.py | 96 ++++++++++ 2 files changed, 259 insertions(+), 21 deletions(-) create mode 100644 mmg_toolbox/diffraction/plotly_colourmaps.py diff --git a/mmg_toolbox/diffraction/msmapper.py b/mmg_toolbox/diffraction/msmapper.py index c4ec19b..e40fe76 100644 --- a/mmg_toolbox/diffraction/msmapper.py +++ b/mmg_toolbox/diffraction/msmapper.py @@ -3,10 +3,13 @@ """ import numpy as np import h5py +import plotly.graph_objects as go +from ipywidgets import VBox, Dropdown, FloatText from mmg_toolbox import version_info from mmg_toolbox.nexus import nexus_writer as nw from mmg_toolbox.fitting import FitResults +from plotly_colourmaps import PLOTLY_CMAPS MSMAPPER_VERSION = '1.9' @@ -57,7 +60,8 @@ def create_bean(input_files, output_file, start=None, shape=None, step=None, "inputs": input_files, # Filename of scan file "output": output_file, # Output filename - must be in processing directory, or somewhere you can write to - "splitterName": "gaussian", # one of the following strings "nearest", "gaussian", "negexp", "inverse" + # one of the following strings "nearest", "gaussian", "negexp", "inverse" + "splitterName": "gaussian", "splitterParameter": 2.0, # splitter's parameter is distance to half-height of the weight function. # If you use None or "" then it is treated as "nearest" @@ -65,9 +69,11 @@ def create_bean(input_files, output_file, start=None, shape=None, step=None, # the oversampling factor for each image; to ensure that are no gaps in between pixels in mapping "step": step, # a single value or list if 3 values and determines the lengths of each side of the voxels in the volume - "start": start, # location in HKL space of the bottom corner of the array. + # location in HKL space of the bottom corner of the array. + "start": start, "shape": shape, # size of the array to create for reciprocal space volume - "reduceToNonZero": reduce_box # True/False, if True, attempts to reduce the volume output + # True/False, if True, attempts to reduce the volume output + "reduceToNonZero": reduce_box } if output_mode: bean['outputMode'] = output_mode @@ -86,12 +92,13 @@ def create_bean(input_files, output_file, start=None, shape=None, step=None, def update_msmapper_nexus(filename: str, hkl_slice: tuple[np.ndarray, np.ndarray, np.ndarray], - orthogonal_axes: tuple[np.ndarray, np.ndarray, np.ndarray] | None = None, - average_axes: tuple[np.ndarray, np.ndarray, np.ndarray] | None = None, + orthogonal_axes: tuple[np.ndarray, + np.ndarray, np.ndarray] | None = None, + average_axes: tuple[np.ndarray, + np.ndarray, np.ndarray] | None = None, fit_options: dict | None = None, h_result: FitResults | None = None, k_result: FitResults | None = None, l_result: FitResults | None = None, q_result: FitResults | None = None, tth_result: FitResults | None = None): - """ Update the NeXus file generated by msmapper with additional analysis data. @@ -191,7 +198,8 @@ def update_msmapper_nexus(filename: str, hkl_slice: tuple[np.ndarray, np.ndarray with h5py.File(filename, 'a') as hdf: entry = nw.add_nxentry(hdf, 'analysis', definition=None, default=True) - nw.add_nxprocess(entry, 'process', program='mmg_toolbox', version=version_info()) + nw.add_nxprocess(entry, 'process', + program='mmg_toolbox', version=version_info()) # hkl h = hdf['/processed/reciprocal_space/h-axis'][:] @@ -205,18 +213,21 @@ def update_msmapper_nexus(filename: str, hkl_slice: tuple[np.ndarray, np.ndarray k_axis['k'] = h5py.SoftLink('/processed/reciprocal_space/k-axis') nw.add_nxfield(k_axis, 'intensity', k_slice) - l_axis = nw.add_nxdata(entry, 'l_axis', ['l'], 'intensity', default=True) + l_axis = nw.add_nxdata( + entry, 'l_axis', ['l'], 'intensity', default=True) l_axis['l'] = h5py.SoftLink('/processed/reciprocal_space/l-axis') nw.add_nxfield(l_axis, 'intensity', l_slice) # Q - orthog = nw.add_nxdata(entry, 'orthogonal_axes', ['Qx', 'Qy', 'Qz'], 'volume', 'weight') + orthog = nw.add_nxdata(entry, 'orthogonal_axes', [ + 'Qx', 'Qy', 'Qz'], 'volume', 'weight') nw.add_nxfield(orthog, 'Qx', qx, units='1/angstrom') nw.add_nxfield(orthog, 'Qy', qy, units='1/angstrom') nw.add_nxfield(orthog, 'Qz', qz, units='1/angstrom') orthog['volume'] = h5py.SoftLink('/processed/reciprocal_space/volume') orthog['weight'] = h5py.SoftLink('/processed/reciprocal_space/weight') - nw.add_attr(orthog, Qx_indices=1, Qy_indices=2, Qz_indices=0, default_slice=len(l) // 2) + nw.add_attr(orthog, Qx_indices=1, Qy_indices=2, + Qz_indices=0, default_slice=len(l) // 2) # volume averaged at each magnitude qvsi = nw.add_nxdata(entry, 'wavevector', ['q'], 'intensity') @@ -231,20 +242,151 @@ def update_msmapper_nexus(filename: str, hkl_slice: tuple[np.ndarray, np.ndarray fit_proc = nw.add_nxprocess(entry, 'peak_fit', program='mmg_toolbox.utils.fitting.multipeakfit', **fit_options) - nw.add_nxnote(fit_proc, 'fit_results_h', 'fit results for h-axis', data=str(h_result)) + nw.add_nxnote(fit_proc, 'fit_results_h', + 'fit results for h-axis', data=str(h_result)) nw.add_nxparameters(fit_proc, 'fit_data_h', **h_result.results()) - nw.add_nxnote(fit_proc, 'fit_results_k', 'fit results for k-axis', data=str(k_result)) + nw.add_nxnote(fit_proc, 'fit_results_k', + 'fit results for k-axis', data=str(k_result)) nw.add_nxparameters(fit_proc, 'fit_data_k', **k_result.results()) - nw.add_nxnote(fit_proc, 'fit_results_l', 'fit results for l-axis', data=str(l_result)) + nw.add_nxnote(fit_proc, 'fit_results_l', + 'fit results for l-axis', data=str(l_result)) nw.add_nxparameters(fit_proc, 'fit_data_l', **l_result.results()) - nw.add_nxnote(fit_proc, 'fit_results_q', 'fit results for wavevector magnitude |Q|', data=str(q_result)) + nw.add_nxnote(fit_proc, 'fit_results_q', + 'fit results for wavevector magnitude |Q|', data=str(q_result)) nw.add_nxparameters(fit_proc, 'fit_data_q', **q_result.results()) - nw.add_nxnote(fit_proc, 'fit_results_tth', 'fit results for remapped two-theta', data=str(tth_result)) - nw.add_nxparameters(fit_proc, 'fit_data_tth', **tth_result.results()) + nw.add_nxnote(fit_proc, 'fit_results_tth', + 'fit results for remapped two-theta', data=str(tth_result)) + nw.add_nxparameters(fit_proc, 'fit_data_tth', + **tth_result.results()) + + nw.add_nxfield(h_axis, 'fit', h_result.fit_data( + h, ntimes=1)[1], add_to_signal=True) + nw.add_nxfield(k_axis, 'fit', k_result.fit_data( + k, ntimes=1)[1], add_to_signal=True) + nw.add_nxfield(l_axis, 'fit', l_result.fit_data( + l, ntimes=1)[1], add_to_signal=True) + nw.add_nxfield(qvsi, 'fit', q_result.fit_data( + wavevector, ntimes=1)[1], add_to_signal=True) + nw.add_nxfield(tthvsi, 'fit', tth_result.fit_data( + tth, ntimes=1)[1], add_to_signal=True) + + +def plot_voxel_image( + h: np.ndarray, + k: np.ndarray, + l: np.ndarray, + values: np.ndarray, + title: str | None = None, + cmap: str = "inferno", + figsize: tuple[int, int] = (9, 6), + isomin: float = 0.001, + isomax: float = 1.0, +): + """ + Plots an interactive 3D volume using Plotly and ipywidgets. + + :param h: 3D array containing h coordinates. + :type h: np.ndarray + + :param k: 3D array containing k coordinates. + :type k: np.ndarray + + :param l: 3D array containing l coordinates. + :type l: np.ndarray + + :param values: 3D array containing the voxel values. + :type values: np.ndarray + + :param title: Title to be displayed on the plot. + :type title: str | None - nw.add_nxfield(h_axis, 'fit', h_result.fit_data(h, ntimes=1)[1], add_to_signal=True) - nw.add_nxfield(k_axis, 'fit', k_result.fit_data(k, ntimes=1)[1], add_to_signal=True) - nw.add_nxfield(l_axis, 'fit', l_result.fit_data(l, ntimes=1)[1], add_to_signal=True) - nw.add_nxfield(qvsi, 'fit', q_result.fit_data(wavevector, ntimes=1)[1], add_to_signal=True) - nw.add_nxfield(tthvsi, 'fit', tth_result.fit_data(tth, ntimes=1)[1], add_to_signal=True) + :param cmap: Name of the colourmap to be used in plot. Defaults to "inferno". + :type cmap: str + + :param figsize: Tuple containing the width and height of the plot in inches. + :type figsize: tuple[int,int] | None + + :param isomin: Float that sets the minimum boudary for the iso-surface plot. + :type isomin: float + + :param isomax: Float that sets the maximum boundary for the iso-surface plot. + :type isomax: float + + :returns: VBox. ipywidgets VBox containing controls and the figure widget. + :rtype ipywidgets.Box: + + :returns: fig.write_image. A function to save the figure to a given filepath + :rtype fig.write_image: + :rtype fig.write_image: function (str) -> void + """ + fig = go.FigureWidget( + data=go.Volume( + x=h.flatten(), + y=k.flatten(), + z=l.flatten(), + value=values, + colorscale=cmap, + isomin=isomin, + isomax=isomax, + opacity=0.1, + surface_count=10, + showscale=False, + ), + layout={ + 'title': title, + 'scene': { + 'xaxis': {'title': 'H (r.l.u.)'}, + 'yaxis': {'title': 'K (r.l.u.)'}, + 'zaxis': {'title': 'L (r.l.u.)'}, + 'aspectmode': 'data', + 'camera': { + 'eye': { + 'x': 1.5, + 'y': 1.5, + 'z': 1.5, + } + } + }, + 'width': figsize[0] * 96, + 'height': figsize[1] * 96, + }, + ) + + colourmap_dropdown = Dropdown( + options=PLOTLY_CMAPS, + value=cmap, + description='Colourmap:', + style={'description_width': 'initial'} + ) + + isomin_input = FloatText( + value=isomin, + description="isomin:", + style={"description_width": "50px"}, + layout={"width": "150px"}, + ) + + isomax_input = FloatText( + value=isomax, + description="isomax:", + style={"description_width": "50px"}, + layout={"width": "150px"}, + ) + + def update_plot(_) -> None: + cmap = colourmap_dropdown.value + vmin = isomin_input.value + vmax = isomax_input.value + + with fig.batch_update(): + fig.data[0].colorscale = cmap + fig.data[0].isomin = vmin + fig.data[0].isomax = vmax + + colourmap_dropdown.observe(update_plot, names='value') + isomin_input.observe(update_plot, names='value') + isomax_input.observe(update_plot, names='value') + + widget = VBox(colourmap_dropdown, fig) + return widget, fig.write_image diff --git a/mmg_toolbox/diffraction/plotly_colourmaps.py b/mmg_toolbox/diffraction/plotly_colourmaps.py new file mode 100644 index 0000000..b87f5fe --- /dev/null +++ b/mmg_toolbox/diffraction/plotly_colourmaps.py @@ -0,0 +1,96 @@ +PLOTLY_CMAPS = [ + 'aggrnyl', + 'agsunset', + 'blackbody', + 'bluered', + 'blues', + 'blugrn', + 'bluyl', + 'brwnyl', + 'bugn', + 'bupu', + 'burg', + 'burgyl', + 'cividis', + 'darkmint', + 'electric', + 'emrld', + 'gnbu', + 'greens', + 'greys', + 'hot', + 'inferno', + 'jet', + 'magenta', + 'magma', + 'mint', + 'orrd', + 'oranges', + 'oryel', + 'peach', + 'pinkyl', + 'plasma', + 'plotly3', + 'pubu', + 'pubugn', + 'purd', + 'purp', + 'purples', + 'purpor', + 'rainbow', + 'rdbu', + 'rdpu', + 'redor', + 'reds', + 'sunset', + 'sunsetdark', + 'teal', + 'tealgrn', + 'turbo', + 'viridis', + 'ylgn', + 'ylgnbu', + 'ylorbr', + 'ylorrd', + 'algae', + 'amp', + 'deep', + 'dense', + 'gray', + 'haline', + 'ice', + 'matter', + 'solar', + 'speed', + 'tempo', + 'thermal', + 'turbid', + 'armyrose', + 'brbg', + 'earth', + 'fall', + 'geyser', + 'prgn', + 'piyg', + 'picnic', + 'portland', + 'puor', + 'rdgy', + 'rdylbu', + 'rdylgn', + 'spectral', + 'tealrose', + 'temps', + 'tropic', + 'balance', + 'curl', + 'delta', + 'oxy', + 'edge', + 'hsv', + 'icefire', + 'phase', + 'twilight', + 'mrybm', + 'mygbm', +] From 7c71f8a463c4f6c823f01d8027e227c6107efc24 Mon Sep 17 00:00:00 2001 From: NatLeung96 Date: Wed, 17 Jun 2026 16:09:30 +0100 Subject: [PATCH 2/5] update dependencies --- pyproject.toml | 90 +++++++++++++++++++++++++++----------------------- 1 file changed, 49 insertions(+), 41 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0216640..e35b12a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,62 +16,70 @@ dependencies = [ "lmfit", "stomp.py", "diffcalc-core", + "ipywidgets", + "kaleido", + "plotly", ] requires-python = ">=3.10" -authors = [ - {name = "Dan Porter", email = "dan.porter@diamond.ac.uk"}, -] -maintainers = [ - {name = "Dan Porter", email = "dan.porter@diamond.ac.uk"}, -] +authors = [{ name = "Dan Porter", email = "dan.porter@diamond.ac.uk" }] +maintainers = [{ name = "Dan Porter", email = "dan.porter@diamond.ac.uk" }] description = "Repository for useful python data analysis functions for the Diamond Magnetic Materials Group" readme = "README.md" -license = {file = "LICENSE"} -keywords = [ - 'nexus', -] +license = { file = "LICENSE" } +keywords = ['nexus'] classifiers = [ - 'Programming Language :: Python :: 3.10', - 'Intended Audience :: Science/Research', - 'Topic :: Scientific/Engineering :: Physics', - 'License :: OSI Approved :: Apache Software License', - 'Development Status :: 3 - Alpha', + 'Programming Language :: Python :: 3.10', + 'Intended Audience :: Science/Research', + 'Topic :: Scientific/Engineering :: Physics', + 'License :: OSI Approved :: Apache Software License', + 'Development Status :: 3 - Alpha', ] [project.optional-dependencies] # minimal install base = [ - "numpy", - "h5py", - "hdf5plugin", - "hdfmap>=1.1.0", - "matplotlib", - "imageio", - "lmfit", + "numpy", + "h5py", + "hdf5plugin", + "hdfmap>=1.1.0", + "matplotlib", + "imageio", + "lmfit", ] # full install with additional packages full = [ - "numpy", - "h5py", - "hdf5plugin", - "hdfmap>=1.1.0", - "matplotlib", - "imageio", - "scipy", - "lmfit", - "diffcalc-core", - "ipython", - "jupyter", - "nbconvert", - "nbformat", - "nexpy" + "numpy", + "h5py", + "hdf5plugin", + "hdfmap>=1.1.0", + "matplotlib", + "imageio", + "scipy", + "lmfit", + "diffcalc-core", + "ipython", + "jupyter", + "nbconvert", + "nbformat", + "nexpy", ] [tool.setuptools] packages = [ - 'mmg_toolbox', 'mmg_toolbox.beamline_metadata', 'mmg_toolbox.data', 'mmg_toolbox.scripts', - 'mmg_toolbox.diffraction', 'mmg_toolbox.plotting', 'mmg_toolbox.nexus', 'mmg_toolbox.utils', 'mmg_toolbox.xas', 'mmg_toolbox.fitting', - 'mmg_toolbox.tkguis', 'mmg_toolbox.tkguis.misc', 'mmg_toolbox.tkguis.widgets', 'mmg_toolbox.tkguis.apps' + 'mmg_toolbox', + 'mmg_toolbox.beamline_metadata', + 'mmg_toolbox.data', + 'mmg_toolbox.scripts', + 'mmg_toolbox.diffraction', + 'mmg_toolbox.plotting', + 'mmg_toolbox.nexus', + 'mmg_toolbox.utils', + 'mmg_toolbox.xas', + 'mmg_toolbox.fitting', + 'mmg_toolbox.tkguis', + 'mmg_toolbox.tkguis.misc', + 'mmg_toolbox.tkguis.widgets', + 'mmg_toolbox.tkguis.apps', ] [tool.setuptools.package-data] @@ -79,8 +87,8 @@ packages = [ "mmg_toolbox.scripts" = ["*.ipynb"] [tool.setuptools.dynamic] -version = {attr = "mmg_toolbox.__version__"} +version = { attr = "mmg_toolbox.__version__" } [project.scripts] dataviewer = "mmg_toolbox.tkguis:cli_run" -create_notebooks = "mmg_toolbox.scripts.experiment_startup:cli_create_notebooks" \ No newline at end of file +create_notebooks = "mmg_toolbox.scripts.experiment_startup:cli_create_notebooks" From 0952b8d1e857bd3aed7a0ca7d3bbabab3ca30202 Mon Sep 17 00:00:00 2001 From: NatLeung96 Date: Thu, 18 Jun 2026 11:37:51 +0100 Subject: [PATCH 3/5] update msmapper --- mmg_toolbox/diffraction/msmapper.py | 16 +++++++++------- pyproject.toml | 6 +++--- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/mmg_toolbox/diffraction/msmapper.py b/mmg_toolbox/diffraction/msmapper.py index e40fe76..efa77fe 100644 --- a/mmg_toolbox/diffraction/msmapper.py +++ b/mmg_toolbox/diffraction/msmapper.py @@ -4,12 +4,14 @@ import numpy as np import h5py import plotly.graph_objects as go -from ipywidgets import VBox, Dropdown, FloatText +from ipywidgets import VBox, HBox, Dropdown, FloatText from mmg_toolbox import version_info from mmg_toolbox.nexus import nexus_writer as nw from mmg_toolbox.fitting import FitResults -from plotly_colourmaps import PLOTLY_CMAPS +from .plotly_colourmaps import PLOTLY_CMAPS + +from collections.abc import Callable MSMAPPER_VERSION = '1.9' @@ -219,8 +221,7 @@ def update_msmapper_nexus(filename: str, hkl_slice: tuple[np.ndarray, np.ndarray nw.add_nxfield(l_axis, 'intensity', l_slice) # Q - orthog = nw.add_nxdata(entry, 'orthogonal_axes', [ - 'Qx', 'Qy', 'Qz'], 'volume', 'weight') + orthog = nw.add_nxdata(entry, 'orthogonal_axes', ['Qx', 'Qy', 'Qz'], 'volume', 'weight') nw.add_nxfield(orthog, 'Qx', qx, units='1/angstrom') nw.add_nxfield(orthog, 'Qy', qy, units='1/angstrom') nw.add_nxfield(orthog, 'Qz', qz, units='1/angstrom') @@ -281,7 +282,7 @@ def plot_voxel_image( figsize: tuple[int, int] = (9, 6), isomin: float = 0.001, isomax: float = 1.0, -): +) -> tuple[VBox, Callable[[str], None]] : """ Plots an interactive 3D volume using Plotly and ipywidgets. @@ -316,7 +317,6 @@ def plot_voxel_image( :rtype ipywidgets.Box: :returns: fig.write_image. A function to save the figure to a given filepath - :rtype fig.write_image: :rtype fig.write_image: function (str) -> void """ @@ -388,5 +388,7 @@ def update_plot(_) -> None: isomin_input.observe(update_plot, names='value') isomax_input.observe(update_plot, names='value') - widget = VBox(colourmap_dropdown, fig) + iso_controls = HBox([isomin_input, isomax_input]) + + widget = VBox([colourmap_dropdown, iso_controls, fig]) return widget, fig.write_image diff --git a/pyproject.toml b/pyproject.toml index b016b3e..05ea27e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,9 +15,6 @@ dependencies = [ "scipy", "lmfit", "stomp.py", - "ipywidgets", - "kaleido", - "plotly", ] requires-python = ">=3.10" authors = [{ name = "Dan Porter", email = "dan.porter@diamond.ac.uk" }] @@ -61,6 +58,9 @@ full = [ "nbconvert", "nbformat", "nexpy", + "ipywidgets", + "kaleido", + "plotly", ] [tool.setuptools] From 90fecb46145102f488e4be3facdd48523130e315 Mon Sep 17 00:00:00 2001 From: NatLeung96 Date: Thu, 18 Jun 2026 13:17:24 +0100 Subject: [PATCH 4/5] update msmapper imports --- mmg_toolbox/diffraction/msmapper.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mmg_toolbox/diffraction/msmapper.py b/mmg_toolbox/diffraction/msmapper.py index efa77fe..e681ebf 100644 --- a/mmg_toolbox/diffraction/msmapper.py +++ b/mmg_toolbox/diffraction/msmapper.py @@ -3,8 +3,6 @@ """ import numpy as np import h5py -import plotly.graph_objects as go -from ipywidgets import VBox, HBox, Dropdown, FloatText from mmg_toolbox import version_info from mmg_toolbox.nexus import nexus_writer as nw @@ -320,6 +318,9 @@ def plot_voxel_image( :rtype fig.write_image: function (str) -> void """ + import plotly.graph_objects as go + from ipywidgets import VBox, HBox, Dropdown, FloatText + fig = go.FigureWidget( data=go.Volume( x=h.flatten(), From 9ad50d9d9a707dbeaee737e514757b32fcfb4f53 Mon Sep 17 00:00:00 2001 From: NatLeung96 Date: Thu, 18 Jun 2026 13:36:06 +0100 Subject: [PATCH 5/5] remove return type annotation --- mmg_toolbox/diffraction/msmapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mmg_toolbox/diffraction/msmapper.py b/mmg_toolbox/diffraction/msmapper.py index e681ebf..b2567f3 100644 --- a/mmg_toolbox/diffraction/msmapper.py +++ b/mmg_toolbox/diffraction/msmapper.py @@ -280,7 +280,7 @@ def plot_voxel_image( figsize: tuple[int, int] = (9, 6), isomin: float = 0.001, isomax: float = 1.0, -) -> tuple[VBox, Callable[[str], None]] : +): """ Plots an interactive 3D volume using Plotly and ipywidgets.