diff --git a/.copier-answers.yml b/.copier-answers.yml index 8b534f63..1ddfc49a 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -2,7 +2,7 @@ _commit: d704227 _src_path: gh:scipp/copier_template description: Reflectometry data reduction for the European Spallation Source -max_python: '3.12' +max_python: '3.13' min_python: '3.10' namespace_package: ess nightly_deps: plopp,sciline,scippneutron,scippnexus,scipp diff --git a/docs/user-guide/amor/gui.ipynb b/docs/user-guide/amor/gui.ipynb index b7c6ed23..c1665ce2 100644 --- a/docs/user-guide/amor/gui.ipynb +++ b/docs/user-guide/amor/gui.ipynb @@ -18,7 +18,7 @@ "metadata": {}, "outputs": [], "source": [ - "#%matplotlib qt" + "%matplotlib widget" ] }, { diff --git a/pyproject.toml b/pyproject.toml index d8e111c6..232328c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Scientific/Engineering", "Typing :: Typed", ] diff --git a/requirements/base.txt b/requirements/base.txt index 1567d0d9..0e48c53e 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -25,13 +25,13 @@ cycler==0.12.1 # via matplotlib dask==2025.2.0 # via -r base.in -decorator==5.1.1 +decorator==5.2.1 # via ipython dnspython==2.7.0 # via email-validator email-validator==2.2.0 # via scippneutron -essreduce==25.2.4 +essreduce==25.3.0 # via -r base.in exceptiongroup==1.2.2 # via ipython @@ -57,7 +57,7 @@ ipydatagrid==1.4.0 # via -r base.in ipydatawidgets==4.3.5 # via pythreejs -ipython==8.32.0 +ipython==8.33.0 # via ipywidgets ipytree==0.2.2 # via -r base.in @@ -76,10 +76,12 @@ jupyterlab-widgets==3.0.13 kiwisolver==1.4.8 # via matplotlib lazy-loader==0.4 - # via scippneutron + # via + # plopp + # scippneutron locket==1.0.0 # via partd -matplotlib==3.10.0 +matplotlib==3.10.1 # via # mpltoolbox # plopp @@ -122,7 +124,7 @@ pexpect==4.9.0 # via ipython pillow==11.1.0 # via matplotlib -plopp==24.10.0 +plopp==25.2.0 # via # -r base.in # scippneutron @@ -167,7 +169,7 @@ scipp==25.2.0 # essreduce # scippneutron # scippnexus -scippneutron==25.2.0 +scippneutron==25.2.1 # via # -r base.in # essreduce diff --git a/requirements/basetest.txt b/requirements/basetest.txt index 4277ad10..929e397d 100644 --- a/requirements/basetest.txt +++ b/requirements/basetest.txt @@ -25,7 +25,7 @@ pluggy==1.5.0 # via pytest pooch==1.8.2 # via -r basetest.in -pytest==8.3.4 +pytest==8.3.5 # via -r basetest.in requests==2.32.3 # via pooch diff --git a/requirements/docs.txt b/requirements/docs.txt index 93d55fde..0f584a39 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -122,7 +122,7 @@ requests==2.32.3 # via # pooch # sphinx -rpds-py==0.23.0 +rpds-py==0.23.1 # via # jsonschema # referencing diff --git a/requirements/nightly.txt b/requirements/nightly.txt index 4f16f985..344cdedd 100644 --- a/requirements/nightly.txt +++ b/requirements/nightly.txt @@ -34,13 +34,13 @@ cycler==0.12.1 # via matplotlib dask==2025.2.0 # via -r nightly.in -decorator==5.1.1 +decorator==5.2.1 # via ipython dnspython==2.7.0 # via email-validator email-validator==2.2.0 # via scippneutron -essreduce==25.2.4 +essreduce==25.3.0 # via -r nightly.in exceptiongroup==1.2.2 # via @@ -72,7 +72,7 @@ ipydatagrid==1.4.0 # via -r nightly.in ipydatawidgets==4.3.5 # via pythreejs -ipython==8.32.0 +ipython==8.33.0 # via ipywidgets ipytree==0.2.2 # via -r nightly.in @@ -97,7 +97,7 @@ lazy-loader==0.4 # scippneutron locket==1.0.0 # via partd -matplotlib==3.10.0 +matplotlib==3.10.1 # via # mpltoolbox # plopp @@ -169,7 +169,7 @@ pygments==2.19.1 # via ipython pyparsing==3.2.1 # via matplotlib -pytest==8.3.4 +pytest==8.3.5 # via -r nightly.in python-dateutil==2.9.0.post0 # via diff --git a/requirements/static.txt b/requirements/static.txt index a0569bda..098c4f5a 100644 --- a/requirements/static.txt +++ b/requirements/static.txt @@ -11,7 +11,7 @@ distlib==0.3.9 # via virtualenv filelock==3.17.0 # via virtualenv -identify==2.6.7 +identify==2.6.8 # via pre-commit nodeenv==1.9.1 # via pre-commit diff --git a/src/ess/reflectometry/gui.py b/src/ess/reflectometry/gui.py index 8c32e9e1..985b25c3 100644 --- a/src/ess/reflectometry/gui.py +++ b/src/ess/reflectometry/gui.py @@ -1,10 +1,9 @@ import glob import os +import uuid import h5py import ipywidgets as widgets -import matplotlib -import numpy as np import pandas as pd import plopp as pp import scipp as sc @@ -28,6 +27,174 @@ from ess.reflectometry.workflow import with_filenames +class NexusExplorer: + def __init__(self, runs_table, run_to_filepath): + self.runs_table = runs_table + self.run_to_filepath = run_to_filepath + + # Create tree widget for Nexus structure + self.nexus_tree = Tree( + layout=widgets.Layout( + width='100%', + height='100%', # Fill the container height + ) + ) + self.nexus_tree.nodes = [Node("Select a run to view its structure")] + + # Add selection handler to runs table + self.runs_table.observe(self.update_nexus_view, names='selections') + + # Create content viewer widget + self.nexus_content = widgets.Textarea( + value='Select a node to view its content', + layout=widgets.Layout(width='100%', height='600px'), + disabled=True, # Make it read-only + ) + + # Add selection handler to tree + self.nexus_tree.observe(self.on_tree_select, names='selected_nodes') + + # Create the Nexus Explorer tab content + self.widget = widgets.VBox( + [ + widgets.Label("Nexus Explorer"), + widgets.HBox( + [ + widgets.VBox( + [ + widgets.Label("Runs Table"), + self.runs_table, + ], + layout={"width": "30%"}, + ), + widgets.VBox( + [ + widgets.Label("File Structure"), + widgets.VBox( + [self.nexus_tree], + layout=widgets.Layout( + width='100%', + height='600px', + min_height='100px', # Min resize height + max_height='1000px', # Max resize height + overflow_y='scroll', + border='1px solid lightgray', + resize='vertical', # Add resize handle + ), + ), + ], + layout={"width": "35%"}, + ), + widgets.VBox( + [ + widgets.Label("Content"), + self.nexus_content, + ], + layout={"width": "35%"}, + ), + ] + ), + ], + layout={"width": "100%"}, + ) + + def create_hdf5_tree(self, filepath): + """Create a tree representation of an HDF5 file structure.""" + + def create_node(name, obj, path=''): + full_path = f"{path}/{name}" if path else name + if isinstance(obj, h5py.Dataset): + # For datasets, show shape and dtype + display_name = f"{name} ({obj.shape}, {obj.dtype})" + node = Node(display_name, opened=False, icon='file') + node.nexus_path = full_path # Store path as custom attribute + return node + else: + # For groups, create parent node and add children + parent = Node(name, opened=False, icon='folder') + parent.nexus_path = full_path # Store path as custom attribute + # Just iterate over the keys directly + for child_name in obj.keys(): + parent.add_node(create_node(child_name, obj[child_name], full_path)) + return parent + + try: + with h5py.File(filepath, 'r') as f: + root_node = create_node('', f) + return Tree(nodes=[root_node]) + except Exception as e: + # Use explicit conversion flag + return Tree(nodes=[Node(f"Error loading file: {e!s}")]) + + def update_nexus_view(self, *_): + """Update the Nexus file viewer based on selected run.""" + selections = self.runs_table.selections + if not selections: + self.nexus_tree.nodes = [Node("Select a run to view its structure")] + return + + # Get the first selected row + row_idx = selections[0]['r1'] + run = self.runs_table.data.iloc[row_idx]['Run'] + filepath = self.run_to_filepath(run) + + # Create and display the tree for this file + new_tree = self.create_hdf5_tree(filepath) + self.nexus_tree.nodes = new_tree.nodes + + def display_nexus_content(self, path, h5file): + """Display the content of a Nexus entry.""" + try: + item = h5file[path] if path else h5file + content = [] + + # Show attributes if any + if len(item.attrs) > 0: + content.append("Attributes:") + for key, value in item.attrs.items(): + content.append(f" {key}: {value}") + + # Show dataset content if it's a dataset + if isinstance(item, h5py.Dataset): + content.append("\nDataset content:") + data = item[()] + if data.size > 100: # Truncate large datasets + content.append(f" Shape: {data.shape}") + content.append(" First few values:") + content.append(f" {data.flatten()[:100]}") + content.append(" ...") + else: + content.append(f" {data}") + + self.nexus_content.value = '\n'.join(content) + except Exception as e: + # Use explicit conversion flag + self.nexus_content.value = f"Error reading content: {e!s}" + + def on_tree_select(self, event): + """Handle tree node selection.""" + if not event['new']: # No selection + self.nexus_content.value = "Select a node to view its content" + return + + selected_node = event['new'][0] + + # Get the path from the custom attribute + path = getattr(selected_node, 'nexus_path', selected_node.name) + + # Get the currently selected run + selections = self.runs_table.selections + if not selections: + return + + row_idx = selections[0]['r1'] + run = self.runs_table.data.iloc[row_idx]['Run'] + filepath = self.run_to_filepath(run) + + with h5py.File(filepath, 'r') as f: + self.display_nexus_content(path, f) + + class ReflectometryBatchReductionGUI: """GUI for batch reduction of reflectometry data. @@ -137,7 +304,6 @@ def __init__(self): self.text_log = widgets.VBox([]) self.progress_log = widgets.VBox([]) self.plot_log = widgets.VBox([]) - self.plot_counter = 0 # Add a counter to create unique IDs for plots self._path = None self.log("init") @@ -279,7 +445,6 @@ def delete_row(_): ) self.run_number_min.observe(self.sync, names='value') self.run_number_max.observe(self.sync, names='value') - run_number_filter = widgets.HBox( [self.run_number_min, widgets.Label("<=Run<="), self.run_number_max] ) @@ -351,83 +516,15 @@ def delete_row(_): layout={"width": "100%"}, ) - # Create tree widget for Nexus structure - self.nexus_tree = Tree( - layout=widgets.Layout( - width='100%', - height='100%', # Fill the container height - ) - ) - self.nexus_tree.nodes = [Node("Select a run to view its structure")] - - # Add selection handler to runs table - self.runs_table.observe(self.update_nexus_view, names='selections') - - # Create content viewer widget - self.nexus_content = widgets.Textarea( - value='Select a node to view its content', - layout=widgets.Layout(width='100%', height='600px'), - disabled=True, # Make it read-only - ) - - # Add selection handler to tree - self.nexus_tree.observe(self.on_tree_select, names='selected_nodes') - - # Create the Nexus Explorer tab content - tab_nexus = widgets.VBox( - [ - widgets.Label("Nexus Explorer"), - widgets.HBox( - [ - widgets.VBox( - [ - widgets.Label("Runs Table"), - self.runs_table, - ], - layout={"width": "30%"}, - ), - widgets.VBox( - [ - widgets.Label("File Structure"), - widgets.VBox( - [self.nexus_tree], - layout=widgets.Layout( - width='100%', - height='600px', - min_height='100px', # Min resize height - max_height='1000px', # Max resize height - overflow_y='scroll', - border='1px solid lightgray', - resize='vertical', # Add resize handle - ), - ), - ], - layout={"width": "35%"}, - ), - widgets.VBox( - [ - widgets.Label("Content"), - self.nexus_content, - ], - layout={"width": "35%"}, - ), - ] - ), - ], - layout={"width": "100%"}, - ) - self.tabs = widgets.Tab() self.tabs.children = [ tab_data, tab_settings, tab_log, - tab_nexus, - ] # Add the new tab + ] self.tabs.set_title(0, "Reduce") self.tabs.set_title(1, "Settings") self.tabs.set_title(2, "Log") - self.tabs.set_title(3, "Nexus Explorer") # Set the title for the new tab self.main = widgets.VBox( [ @@ -458,396 +555,16 @@ def log_progress(self, progress): self.progress_log.children = (progress,) def log_plot(self, plot): - """Log a plot with a remove button and comment box.""" - # Create unique ID for this plot group - currently unused - self.plot_counter += 1 - - # Convert any existing interactive plots to static - # NOTE: This conversion is not working as intended - plots remain interactive - for child in self.plot_log.children: - if isinstance(child, widgets.VBox): - plot_output = child.children[1] # Get the plot output widget - with plot_output: - # Clear the output and redisplay as static - plot_output.clear_output() - if hasattr(plot_output, 'current_plot'): - display(plot_output.current_plot) - - # Create the plot output for the new plot - plot_output = widgets.Output() - plot_output.current_plot = plot - - # Create legend checkboxes container - legend_container = widgets.HBox( - layout=widgets.Layout(flex_wrap='wrap', align_items='center', padding='5px') - ) - - # Store original data for x4 transform - original_data = {} - - # Store original methods before defining custom ones - original_zoom = plot.view.canvas.zoom - original_panzoom = plot.view.canvas.panzoom - original_draw = plot.view.canvas.draw - - # Define all helper functions first - def calculate_plot_limits(current_plot, is_transformed=False): - """Calculate proper limits from all artists' data""" - artists = current_plot.artists - all_y_values = [] - all_y_errors = [] - for artist in artists.values(): - valid_mask = ~np.isnan(artist._data.values) & ~np.isinf( - artist._data.values - ) - all_y_values.extend(artist._data.values[valid_mask]) - if artist._data.variances is not None: - error_mask = valid_mask & ~np.isinf(np.sqrt(artist._data.variances)) - all_y_errors.extend(np.sqrt(artist._data.variances[error_mask])) - - if all_y_values: - y_min = min(all_y_values) - y_max = max(all_y_values) - if all_y_errors: - y_min = min(y_min, min(all_y_values - np.array(all_y_errors))) - y_max = max(y_max, max(all_y_values + np.array(all_y_errors))) - - # Handle negative values for log scale - if y_min <= 0: - positive_values = [y for y in all_y_values if y > 0] - if positive_values: - y_min = min(positive_values) - if all_y_errors: - positive_indices = [ - i for i, y in enumerate(all_y_values) if y > 0 - ] - error_values = [all_y_errors[i] for i in positive_indices] - if ( - error_values - ): # Only process if we have valid error values - y_min = min( - y_min, min(positive_values - np.array(error_values)) - ) - - # Add padding (5% on log scale) - log_range = np.log10(y_max) - np.log10(y_min) - padding = 0.05 * log_range - y_min = y_min * 10 ** (-padding) - y_max = y_max * 10**padding - - # Use different minimum limits for transformed vs untransformed data - if is_transformed: - # For y*x^4 data, use 3 orders of magnitude range - y_min = max(y_min, y_max * 1e-3) - else: - # For original data, use 6 orders of magnitude range - y_min = max(y_min, y_max * 1e-6) - - return min(y_min, y_max), max(y_min, y_max) - return None - - def fix_axis_orientation(current_plot, is_transformed=False): - """Helper function to ensure correct axis orientation""" - canvas = current_plot.view.canvas - if hasattr(canvas, 'ax'): - ax = canvas.ax - - # Calculate proper limits from data - limits = calculate_plot_limits(current_plot, is_transformed) - if limits is not None: - plot_y_min, plot_y_max = limits - else: - # If no valid limits, use current ones - y_min, y_max = ax.get_ylim() - plot_y_min = min(y_min, y_max) - plot_y_max = max(y_min, y_max) - - # Force log scale and correct orientation - ax.set_yscale('log') - ax.set_ylim(plot_y_min, plot_y_max) - if ax.yaxis.get_inverted(): - ax.invert_yaxis() - - # Update canvas state to match - canvas.yscale = 'log' - canvas.yrange = (plot_y_min, plot_y_max) - - # Force a redraw to ensure changes take effect - ax.figure.canvas.draw() - - def custom_reset_mode(): - """Custom reset mode that maintains correct orientation""" - fix_axis_orientation(plot) - - def custom_zoom(event=None): - """Custom zoom that maintains correct orientation""" - if event is not None: - original_zoom(event) - else: - original_zoom() - fix_axis_orientation(plot) - - def custom_panzoom(event=None): - """Custom panzoom that maintains correct orientation""" - if event is not None: - original_panzoom(event) - else: - original_panzoom() - fix_axis_orientation(plot) - - def custom_draw(*args, **kwargs): - """Custom draw that maintains correct orientation""" - original_draw(*args, **kwargs) - fix_axis_orientation(plot) - - def apply_x4_transform(change): - """Apply or revert x4 transformation.""" - # Get the current plot - current_plot = plot_output.current_plot - # Get the figure's artists - artists = current_plot.artists - - # Apply or revert transformation for each artist - for name, artist in artists.items(): - if change['new']: # Button is pressed - apply transformation - # Store original data if not already stored - if name not in original_data: - original_data[name] = artist._data.copy() - - # Get x and y data from the artist - x_data = artist._coord.values - y_data = artist._data - - # Make sure x_data matches y_data shape - if x_data.shape != y_data.values.shape: - x_centers = (x_data[1:] + x_data[:-1]) / 2 - new_values = y_data.copy() - valid_mask = ~np.isnan(y_data.values) - transformed_values = y_data.values * x_centers**4 - new_values.values = np.where( - valid_mask, transformed_values, y_data.values - ) - if y_data.variances is not None: - new_values.variances = np.where( - valid_mask, - y_data.variances * x_centers**8, - y_data.variances, - ) - else: - new_values = y_data.copy() - valid_mask = ~np.isnan(y_data.values) - transformed_values = y_data.values * x_data**4 - new_values.values = np.where( - valid_mask, transformed_values, y_data.values - ) - if y_data.variances is not None: - new_values.variances = np.where( - valid_mask, - y_data.variances * x_data**8, - y_data.variances, - ) - else: # Button is unpressed - revert to original - new_values = original_data[name].copy() - - # Update the line with new values - artist.update(new_values) - - # Update view limits to show all data - fix_axis_orientation(current_plot, is_transformed=change['new']) - - # Update without autoscaling - current_plot.update() - - # Now override the methods - plot.view.canvas.reset_mode = custom_reset_mode - plot.view.canvas.zoom = custom_zoom - plot.view.canvas.panzoom = custom_panzoom - plot.view.canvas.draw = custom_draw - - # Create and populate legend controls - def create_legend_controls(): - legend_controls = [] - if hasattr(plot.view.canvas, 'ax'): - ax = plot.view.canvas.ax - handles, labels = ax.get_legend_handles_labels() - - # FIXME: Current limitation - Toggle does not affect error bars - # This is because the error bars are separate artists in matplotlib - # A fix would require finding and toggling associated error bar artists - for handle, label in zip(handles, labels, strict=True): - checkbox = widgets.Checkbox( - value=True, - description=label, - indent=False, - layout=widgets.Layout(margin='0 10px 0 0'), - ) - - def make_toggle_callback(artist_handle): - def toggle_visibility(change): - artist_handle.set_visible(change['new']) - plot.view.canvas.draw() - - return toggle_visibility - - checkbox.observe(make_toggle_callback(handle), names='value') - legend_controls.append(checkbox) - return legend_controls - - # Update legend container - def update_legend(): - legend_container.children = create_legend_controls() - - # Display plot and create initial legend controls - with plot_output: - display(plot) - update_legend() - - # Create buttons and comment box - remove_button = widgets.Button(description='Remove plot') - x4_button = widgets.ToggleButton(description='R*Q⁴') - comment_box = widgets.Textarea( - placeholder='Add comments about this plot here...', - layout=widgets.Layout(width='75%', height='40px'), - ) - - # Connect the x4 transform button - x4_button.observe(apply_x4_transform, names='value') - - # Create container for plot controls with legend - controls_container = widgets.VBox( - [ # Change to VBox for vertical stacking - widgets.HBox( - [ # First row with buttons and comment - widgets.VBox( - [remove_button, x4_button] - ), # Stack these vertically - comment_box, - ], - layout=widgets.Layout(width='100%'), - ), - widgets.VBox( - [ # Second row with legend controls - widgets.Label('Toggle Datasets:'), - legend_container, - ], - layout=widgets.Layout(margin='10px 0'), - ), # Add vertical margin - ], - layout=widgets.Layout(width='100%', align_items='flex-start'), - ) - - # Create vertical container for all elements - plot_container = widgets.VBox([controls_container, plot_output]) - - def remove_plot(_): - # Find and remove this plot container - self.plot_log.children = tuple( - child for child in self.plot_log.children if child is not plot_container - ) - - remove_button.on_click(remove_plot) - - # Add the new plot container at the top - self.plot_log.children = (plot_container, *self.plot_log.children) - - def create_hdf5_tree(self, filepath): - """Create a tree representation of an HDF5 file structure.""" - - def create_node(name, obj, path=''): - full_path = f"{path}/{name}" if path else name - if isinstance(obj, h5py.Dataset): - # For datasets, show shape and dtype - display_name = f"{name} ({obj.shape}, {obj.dtype})" - node = Node(display_name, opened=False, icon='file') - node.nexus_path = full_path # Store path as custom attribute - return node - else: - # For groups, create parent node and add children - parent = Node(name, opened=False, icon='folder') - parent.nexus_path = full_path # Store path as custom attribute - # Just iterate over the keys directly - for child_name in obj.keys(): - parent.add_node(create_node(child_name, obj[child_name], full_path)) - return parent - - try: - with h5py.File(filepath, 'r') as f: - root_node = create_node('', f) - return Tree(nodes=[root_node]) - except Exception as e: - # Use explicit conversion flag - return Tree(nodes=[Node(f"Error loading file: {e!s}")]) - - def update_nexus_view(self, *_): - """Update the Nexus file viewer based on selected run.""" - selections = self.runs_table.selections - if not selections: - self.nexus_tree.nodes = [Node("Select a run to view its structure")] - return - - # Get the first selected row - row_idx = selections[0]['r1'] - run = self.runs_table.data.iloc[row_idx]['Run'] - filepath = self.get_filepath_from_run(run) - - # Create and display the tree for this file - new_tree = self.create_hdf5_tree(filepath) - self.nexus_tree.nodes = new_tree.nodes - - def display_nexus_content(self, path, h5file): - """Display the content of a Nexus entry.""" - try: - item = h5file[path] if path else h5file - content = [] - - # Show attributes if any - if len(item.attrs) > 0: - content.append("Attributes:") - for key, value in item.attrs.items(): - content.append(f" {key}: {value}") - - # Show dataset content if it's a dataset - if isinstance(item, h5py.Dataset): - content.append("\nDataset content:") - data = item[()] - if data.size > 100: # Truncate large datasets - content.append(f" Shape: {data.shape}") - content.append(" First few values:") - content.append(f" {data.flatten()[:100]}") - content.append(" ...") - else: - content.append(f" {data}") - - self.nexus_content.value = '\n'.join(content) - except Exception as e: - # Use explicit conversion flag - self.nexus_content.value = f"Error reading content: {e!s}" - - def on_tree_select(self, event): - """Handle tree node selection.""" - if not event['new']: # No selection - self.nexus_content.value = "Select a node to view its content" - return - - selected_node = event['new'][0] - - # Get the path from the custom attribute - path = getattr(selected_node, 'nexus_path', selected_node.name) - - # Get the currently selected run - selections = self.runs_table.selections - if not selections: - return - - row_idx = selections[0]['r1'] - run = self.runs_table.data.iloc[row_idx]['Run'] - filepath = self.get_filepath_from_run(run) - - with h5py.File(filepath, 'r') as f: - self.display_nexus_content(path, f) + """Log a plot to the top of the plot log""" class AmorBatchReductionGUI(ReflectometryBatchReductionGUI): + def __init__(self): + super().__init__() + self.nexus_explorer = NexusExplorer(self.runs_table, self.get_filepath_from_run) + self.tabs.children = (*self.tabs.children, self.nexus_explorer.widget) + self.tabs.set_title(len(self.tabs.children) - 1, "Nexus Explorer") + def read_meta_data(self, path): with h5py.File(path) as f: return { @@ -885,9 +602,7 @@ def sync_runs_table(self, db): ] self._setdefault(df, "Exclude", False) self._setdefault(df, "Comment", "") # Add default empty comment - df = self._ordercolumns( - df, 'Run', 'Sample', 'Angle', 'Exclude', 'Comment' - ) # Reorder columns + df = self._ordercolumns(df, 'Run', 'Sample', 'Angle', 'Exclude', 'Comment') return df.sort_values(by='Run') def sync_reduction_table(self, db): @@ -974,29 +689,79 @@ def get_unique_names(df): unique.append(f'{name}_{duplicated_name_counter[name]}') return unique - all_runs_plot = pp.plot( - dict(zip(get_unique_names(df), results, strict=True)), - norm='log', - vmin=max(1e-6, min(result.min().value for result in results)), - figsize=(12, 6), # Make figure wider initially + results = dict(zip(get_unique_names(df), results, strict=True)) + + q4toggle = widgets.ToggleButton(value=False, description="R*Q^4") + plot_box = widgets.VBox( + [ + pp.plot( + results, + norm='log', + figsize=(12, 6), + ) + ] ) + curve_toggles = [ + widgets.Checkbox(value=True, description=name) for name in results.keys() + ] - # Adjust the figure and legend - if hasattr(all_runs_plot.view.canvas, 'ax'): - ax = all_runs_plot.view.canvas.ax - # Move legend outside - ax.legend(bbox_to_anchor=(1.05, 0.5), loc='center left') - # Adjust layout to prevent legend cutoff - if hasattr(all_runs_plot.view.canvas, 'fig'): - fig = all_runs_plot.view.canvas.fig - fig.tight_layout() - # Adjust subplot parameters to make room for legend - fig.subplots_adjust(right=0.85) - - if matplotlib.get_backend().lower().startswith('qt'): - all_runs_plot.show() - else: - self.log_plot(all_runs_plot) + def make_plot(change): + plot_box.children[0].ax.clear() + plot = pp.plot( + {k: v * sc.midpoints(v.coords['Q']) ** 4 for k, v in results.items()} + if change['new'] + else results, + norm='log', + ax=plot_box.children[0].ax, + ) + for toggle in curve_toggles: + toggle_line(toggle.description, toggle.value, plot) + plot_box.children = (plot,) + + q4toggle.observe(make_plot, names='value') + + remove_button = widgets.Button(icon='trash-alt', layout={'width': '40px'}) + remove_button.unique_id = uuid.uuid4() + + def remove_plot(own): + self.plot_log.children = tuple( + box for box in self.plot_log.children if own.unique_id != box.unique_id + ) + + remove_button.on_click(remove_plot) + + def toggle_line(name, value, figure): + view = figure.view + for artist in view.artists.values(): + if artist.label == name: + artist._line.set_visible(value) + artist._mask.set_visible(value) + if artist._error is not None: + for c in artist._error.get_children(): + c.set_visible(value) + view.canvas.draw() + + for toggle in curve_toggles: + toggle.observe( + lambda change: toggle_line( + change['owner'].description, change['new'], plot_box.children[0] + ), + names='value', + ) + + comment_box = widgets.Textarea( + placeholder='Add comments about this plot here...', + layout=widgets.Layout(width='75%', height='40px'), + ) + box = widgets.VBox( + [ + widgets.HBox([remove_button, q4toggle, comment_box]), + widgets.HBox(curve_toggles), + plot_box, + ] + ) + box.unique_id = remove_button.unique_id + self.plot_log.children = (box, *self.plot_log.children) def get_filepath_from_run(self, run): return os.path.join(self.path, f'amor2024n{run:0>6}.hdf')