From 01593414680d5c564a8aecf26ee2b79428a160bf Mon Sep 17 00:00:00 2001 From: grace227 Date: Tue, 4 Nov 2025 13:18:31 -0600 Subject: [PATCH 01/21] add step1d scan and param_tuning functions --- src/eaa/tools/imaging/aps_mic/acquisition.py | 172 +++++++++---- src/eaa/tools/imaging/aps_mic/bluesky_init.py | 13 - src/eaa/tools/imaging/aps_mic/param_tuning.py | 66 +++++ src/eaa/tools/imaging/aps_mic/scan_control.py | 38 +++ src/eaa/tools/imaging/aps_mic/util.py | 228 ++++++++---------- 5 files changed, 331 insertions(+), 186 deletions(-) delete mode 100644 src/eaa/tools/imaging/aps_mic/bluesky_init.py create mode 100644 src/eaa/tools/imaging/aps_mic/param_tuning.py create mode 100644 src/eaa/tools/imaging/aps_mic/scan_control.py diff --git a/src/eaa/tools/imaging/aps_mic/acquisition.py b/src/eaa/tools/imaging/aps_mic/acquisition.py index a66d114..ac52160 100644 --- a/src/eaa/tools/imaging/aps_mic/acquisition.py +++ b/src/eaa/tools/imaging/aps_mic/acquisition.py @@ -3,8 +3,15 @@ import os from eaa.tools.imaging.acquisition import AcquireImage -from eaa.tools.imaging.aps_mic.util import process_xrfdata, save_xrfdata +from eaa.tools.imaging.param_tuning import SetParameters +from eaa.tools.imaging.aps_mic.util import ( + process_xrfdata, + save_xrf_line_scan, + validate_position_in_range, +) from eaa.util import wait_for_file +# from eaa.tools.imaging.aps_mic.bluesky_init import BlueskyScanControl +from eaa.tools.imaging.acquisition import AcquireImage logger = logging.getLogger(__name__) @@ -12,24 +19,29 @@ class BlueSkyAcquireImage(AcquireImage): from bluesky.run_engine import RunEngine - from mic_common.devices.save_data import SaveDataMic from typing import Callable + from mic_common.devices.save_data import SaveDataMic + from mic_vis.s2idd.xrf_eaa import save_xrfdata name: str = "bluesky_acquire_image" RE: RunEngine = None - scanplan: Callable = None savedata: SaveDataMic = None + scan2d_plan: Callable = None + scan1d_plan: Callable = None def __init__( self, sample_name: str = "smp1", - dwell: float = 0, + dwell_imaging: float = 0.05, + dwell_line_scan: float = 0.2, xrf_on: bool = True, preamp1_on: bool = False, using_xrf_maps: bool = False, xrf_elms: Tuple[str, ...] = ("Cr",), + xrf_roi_num: int = 16, allowable_x_range: Optional[Tuple[float, float]] = None, allowable_y_range: Optional[Tuple[float, float]] = None, + allowable_z_range: Optional[Tuple[float, float]] = None, require_approval: bool = False, *args, **kwargs ): @@ -39,8 +51,10 @@ def __init__( ---------- sample_name : str, optional The name of the sample. - dwell : float, optional - The dwell time. + dwell_imaging : float, optional + The dwell time in the unit of seconds for imaging. + dwell_line_scan : float, optional + The dwell time in the unit of seconds for line scan. xrf_on : bool, optional Whether to collect XRF data. preamp1_on : bool, optional @@ -59,26 +73,18 @@ def __init__( ImportError If Bluesky control initialization fails. """ - try: - from eaa.tools.imaging.aps_mic.bluesky_init import RE, fly2d, get_control_components - self.RE = RE - self.scanplan = fly2d - self.savedata = get_control_components("savedata") - except ImportError: - raise ImportError( - "Bluesky control initialization failed. " - "Please check that the bluesky-mic package is installed " - "and the motors can only be reached from private subnet computers." - ) - + self.sample_name = sample_name - self.dwell = dwell + self.dwell_imaging = dwell_imaging + self.dwell_line_scan = dwell_line_scan self.xrf_on = xrf_on self.preamp1_on = preamp1_on self.using_xrf_maps = using_xrf_maps self.xrf_elms = xrf_elms + self.xrf_roi_num = xrf_roi_num self.allowable_x_range = allowable_x_range self.allowable_y_range = allowable_y_range + self.allowable_z_range = allowable_z_range super().__init__(*args, require_approval=require_approval, **kwargs) def acquire_image( @@ -116,33 +122,25 @@ def acquire_image( """ self.update_image_acquisition_call_history(x_center, y_center, width, height, stepsize_x, stepsize_y) try: - if self.allowable_x_range: - if x_center < self.allowable_x_range[0] or x_center > self.allowable_x_range[1]: - raise ValueError( - f"The scan center position in the x direction {x_center} um is out " - f"of the allowable range {self.allowable_x_range} um." - ) - if self.allowable_y_range: - if y_center < self.allowable_y_range[0] or y_center > self.allowable_y_range[1]: - raise ValueError( - f"The scan center position in the y direction {y_center} um is out " - f"of the allowable range {self.allowable_y_range} um." - ) - + validate_position_in_range(x_center, self.allowable_x_range, "x") + validate_position_in_range(y_center, self.allowable_y_range, "y") logger.info(f"Acquiring image of size {width} um x {height} um at location {x_center} um, {y_center} um.") - self.savedata.update_next_file_name() - self.RE(self.scanplan( - samplename=self.sample_name, - width=width, - x_center=x_center, - stepsize_x=stepsize_x, - height=height, - y_center=y_center, - stepsize_y=stepsize_y, - dwell=self.dwell, - xrf_on=self.xrf_on, - preamp1_on=self.preamp1_on, - )) + + if self.RE is not None: + self.RE(self.scan2d_plan( + samplename=self.sample_name, + width=width, + x_center=x_center, + stepsize_x=stepsize_x, + height=height, + y_center=y_center, + stepsize_y=stepsize_y, + dwell_ms=self.dwell_imaging*1000, + xrf_on=self.xrf_on, + preamp1_on=self.preamp1_on, + )) + else: + raise ValueError("RunEngine is not initialized.") mda_path = self.savedata.full_path_name.get() mda_dir = mda_path.replace("data1", "mnt/micdata1") @@ -189,3 +187,87 @@ def acquire_image( except Exception as e: logger.error(f"Error acquiring image: {e}") raise e + + + def acquire_line_scan( + self, + width: Annotated[float, "The width of the scan area in microns"] = 0, + x_center: Annotated[float, "The center of the scan area in the x direction in microns"] = None, + stepsize_x: Annotated[float, "The scan step size in the x direction, i.e., the distance between two adjacent pixels in the x direction in microns"] = 0, + + )->Annotated[str, "The path to the plot of the line scan."]: + """Acquire a line scan of a given width at a given center position. + + Parameters + ---------- + width: float + The width of the scan area in microns. + x_center: float + The center of the scan area in the x direction in microns. + stepsize_x: float + The scan step size in the x direction, i.e., the distance between + two adjacent pixels in the x direction in microns. + sample_z: float + The sample z position in millimeters. + + Returns + ------- + str + The path of the plot of the line scan saved in hard drive. + + """ + start_x = x_center - width/2 + end_x = x_center + width/2 + self.update_line_scan_call_history(start_x=start_x, end_x=end_x, step=stepsize_x, start_y=None, end_y=None) + + try: + validate_position_in_range(start_x, self.allowable_x_range, "x") + validate_position_in_range(end_x, self.allowable_x_range, "x") + logger.info(f"Acquiring line scan of width {width} um at center location of {x_center} um.") + + if self.RE is not None: + self.RE(self.scan1d_plan( + samplename=self.sample_name, + width=width, + x_center=x_center, + stepsize_x=stepsize_x, + dwell_ms=self.dwell_line_scan*1000, + xrf_on=self.xrf_on, + preamp1_on=self.preamp1_on, + )) + else: + raise ValueError("RunEngine is not initialized.") + + mda_path = self.savedata.full_path_name.get() + mda_dir = mda_path.replace("data1", "mnt/micdata1") + parent_dir = os.path.dirname(os.path.dirname(mda_dir)) + png_output_dir = os.path.join(parent_dir, "png_output") + current_mda_file = self.savedata.next_file_name + mda_file_path = os.path.join(mda_dir, current_mda_file) + + logger.info(f"About to process the data... {current_mda_file}") + process_code = wait_for_file(mda_file_path, duration=20) + + if process_code: + if not os.path.exists(png_output_dir): + os.makedirs(png_output_dir) + + img_path, img_arr = save_xrf_line_scan( + mda_file_path, png_output_dir, roi_num=self.xrf_roi_num, + return_line_array=True + ) + wait_for_file(img_path, duration=5) + + # self.update_line_scan_buffers(img_arr, psize=stepsize_x) + if img_path: + return img_path + else: + logger.error(f"Failed to save images for {current_mda_file}") + return f"Failed to save images for {current_mda_file}" + logger.error(f"Failed to process {current_mda_file}") + return f"Failed to process {current_mda_file}" + + except Exception as e: + logger.error(f"Error acquiring line scan: {e}") + raise e + diff --git a/src/eaa/tools/imaging/aps_mic/bluesky_init.py b/src/eaa/tools/imaging/aps_mic/bluesky_init.py deleted file mode 100644 index e6ab1f5..0000000 --- a/src/eaa/tools/imaging/aps_mic/bluesky_init.py +++ /dev/null @@ -1,13 +0,0 @@ -#initiating and loading the bluesky environment for the ISN - -# ruff: noqa: F403 -# from isn.startup import * -from s2idd_uprobe.startup import * - - -def get_control_components(device_name: str): - try: - # ruff: noqa: F405 - return oregistry[device_name] - except KeyError: - raise KeyError(f"Device {device_name} not found in the oregistry") diff --git a/src/eaa/tools/imaging/aps_mic/param_tuning.py b/src/eaa/tools/imaging/aps_mic/param_tuning.py new file mode 100644 index 0000000..fdb2dcd --- /dev/null +++ b/src/eaa/tools/imaging/aps_mic/param_tuning.py @@ -0,0 +1,66 @@ +from eaa.tools.imaging.param_tuning import SetParameters +from eaa.tools.imaging.aps_mic.util import validate_position_in_range +from typing import Optional, Tuple +import logging + +logger = logging.getLogger(__name__) + + +class BlueskyParameterTuning(SetParameters): + + from bluesky.run_engine import RunEngine + from typing import Callable + from ophyd import EpicsMotor + import bluesky.plan_stubs as bps + + name: str = "bluesky_parameter_tuning" + samz_motor: EpicsMotor = None + RE: RunEngine = None + allowable_z_range: Optional[Tuple[float, float]] = None + bps: Callable = bps + + def __init__( + self, + parameter_names: list[str] = ['sample-z'], + parameter_ranges: list[tuple[float, ...], tuple[float, ...]] = [[-0.5], [0.5]], + require_approval: bool = False, + *args, **kwargs + ): + """Parameter tuning tool for the imaging system. + + Parameters + ---------- + parameter_names: list[str] + The names of the parameters. + parameter_ranges: list[tuple[float, ...], tuple[float, ...]] + The ranges of the parameters. It should be given as a list of 2 sub-lists, + with the first sub-list containing the lower bounds and the second + sub-list containing the upper bounds. These ranges will be used to + scale the parameter errors. As such, inf is not allowed. + The unit is in millimeters. + require_approval: bool + Whether to require approval for the parameter tuning. + """ + + # self.parameter_names = parameter_names + # self.parameter_ranges = parameter_ranges + + super().__init__(parameter_names, parameter_ranges, *args, **kwargs) + + def set_parameters(self, parameters: list[float]) -> str: + """Set the sample z motor position of the imaging system.""" + if self.RE is None: + raise ValueError("RunEngine is not set") + if self.samz_motor is None: + raise ValueError("samz_motor is not set") + if self.parameter_ranges is not None: + validate_position_in_range( + parameters[0], + (self.parameter_ranges[0], self.parameter_ranges[1]), + "z") + self.RE(self.bps.mv(self.samz_motor, parameters[0])) + msg = f"Move sample z motor to position: {parameters[0]}" + logger.info(msg) + return msg + else: + raise ValueError("parameter_ranges is not set") diff --git a/src/eaa/tools/imaging/aps_mic/scan_control.py b/src/eaa/tools/imaging/aps_mic/scan_control.py new file mode 100644 index 0000000..f397a8d --- /dev/null +++ b/src/eaa/tools/imaging/aps_mic/scan_control.py @@ -0,0 +1,38 @@ +#initiating and loading the bluesky environment for the ISN + +# ruff: noqa: F403 + +from eaa.tools.imaging.aps_mic.acquisition import BlueSkyAcquireImage +from eaa.tools.imaging.aps_mic.param_tuning import BlueskyParameterTuning + + +class BlueskyScanControl(BlueSkyAcquireImage, BlueskyParameterTuning): + + from typing import Callable + from ophyd import EpicsMotor + + samz_motor: EpicsMotor = None + scan2d_plan: Callable = None + scan1d_plan: Callable = None + acquire_image_tool: BlueSkyAcquireImage = None + param_tuning_tool: BlueskyParameterTuning = None + + def __init__(self): + try: + from s2idd_uprobe.startup import RE, oregistry, fly2d_scanrecord, step1d_scanrecord, bps + except ImportError: + raise ImportError( + "Bluesky control initialization failed. Please check that the bluesky-mic package is installed " + "and the motors can only be reached from private subnet computers." + ) + + self.acquire_image_tool = BlueSkyAcquireImage() + self.acquire_image_tool.RE = RE + self.acquire_image_tool.savedata = oregistry["savedata"] + self.acquire_image_tool.scan2d_plan = fly2d_scanrecord + self.acquire_image_tool.scan1d_plan = step1d_scanrecord + + self.param_tuning_tool = BlueskyParameterTuning() + self.param_tuning_tool.RE = RE + self.param_tuning_tool.samz_motor = oregistry["samz"] + diff --git a/src/eaa/tools/imaging/aps_mic/util.py b/src/eaa/tools/imaging/aps_mic/util.py index 9e5df4f..4a7ee11 100644 --- a/src/eaa/tools/imaging/aps_mic/util.py +++ b/src/eaa/tools/imaging/aps_mic/util.py @@ -8,10 +8,47 @@ import numpy as np import matplotlib.pyplot as plt import logging +from typing import Optional, Tuple +from mic_vis.s2idd.mda import get_roi_from_mda +import eaa.maths logger = logging.getLogger(__name__) +def validate_position_in_range( + center: Optional[float], + allowable_range: Optional[Tuple[float, float]], + axis_label: str, +) -> None: + """Validate that a center position lies within an allowable range.""" + if allowable_range is None: + return + + if len(allowable_range) != 2: + raise ValueError( + f"The allowable range for the {axis_label} direction must contain exactly two values." + ) + + lower, upper = allowable_range + if lower > upper: + raise ValueError( + f"The allowable range for the {axis_label} direction " + f"({allowable_range}) has the lower bound greater than the upper bound." + ) + + if center is None: + raise ValueError( + f"The scan center position in the {axis_label} direction must be provided " + "when an allowable range is set." + ) + + if not lower <= center <= upper: + raise ValueError( + f"The scan center position in the {axis_label} direction {center} um is out " + f"of the allowable range {allowable_range} um." + ) + + def run_xrfmaps_exe(exe_path, args=None): """ Runs an executable file. @@ -108,154 +145,89 @@ def process_xrfdata( return None -def load_h5(img_h5_path, fit_type=["NNLS", "ROI"], fsizelim=1e3) -> dict: - """ - Load the XRF data from the h5 file. - - Parameters - ---------- - img_h5_path : str - The path to the h5 file. - fit_type : list - The type of fitting. - fsizelim : float - The size limit of the h5 file, only load - the h5 file larger than this size. - - Returns - ------- - dict - The data from the h5 file. +def plot_xrf_line_scan(x, y, val_gauss, fwhm, scan_name, roi_num): """ - data = {} - fsize = os.path.getsize(img_h5_path) - if fsize > fsizelim: - with h5py.File(img_h5_path, "r") as f: - data.update({"scan": os.path.basename(img_h5_path)}) - data.update({"x_axis": f["MAPS/Scan/x_axis"][:]}) - data.update({"y_axis": f["MAPS/Scan/y_axis"][:]}) - for t in fit_type: - d = f[f"MAPS/XRF_Analyzed/{t}/Counts_Per_Sec"][:] - d_ch = f[f"MAPS/XRF_Analyzed/{t}/Channel_Names"][:].astype(str).tolist() - scaler_names = f["MAPS/Scalers/Names"][:].astype(str).tolist() - scaler_values = f["MAPS/Scalers/Values"][:] - - data.update({f"{t}_arr": d}) - data.update({f"{t}_ch": d_ch}) - data.update({f"{t}_scaler_names": scaler_names}) - data.update({f"{t}_scaler_values": scaler_values}) - - return data - else: - print(f"The XRF h5 file {img_h5_path} not found") - return None - - -def plot_xrfdata(plotarr, xaxis, yaxis, scan_name, elm_name, cmap, vmax, vmin): + Plot the XRF line scan data. """ - Plot the XRF data. - - Parameters - ---------- - plotarr : numpy.ndarray - The array to plot. - xaxis : numpy.ndarray - The x-axis data. - yaxis : numpy.ndarray - The y-axis data. - scan_name : str - The name of the scan. - elm_name : str - The name of the element. - cmap : str - The colormap to use. - vmax : float - The maximum value of the colorbar. - vmin : float - The minimum value of the colorbar. - - Returns - ------- - matplotlib.figure.Figure - The figure object. - """ - fig, ax = plt.subplots(figsize=(5, 5)) - ax.imshow(plotarr, cmap=cmap, vmax=vmax, vmin=vmin) - ax.set_title(f"{scan_name} {elm_name}") - - # Show only 5 ticks for both x- and y- axes - xticks = np.linspace(0, len(xaxis) - 1, 5, dtype=int) - yticks = np.linspace(0, len(yaxis) - 1, 5, dtype=int) - ax.set_xticks(xticks) - ax.set_yticks(yticks) - ax.set_xticklabels([np.round(xaxis[i], 2) for i in xticks]) - ax.set_yticklabels([np.round(yaxis[i], 2) for i in yticks]) - ax.tick_params(axis="both", which="major", labelsize=12) + fig, ax = plt.subplots(1, 1, squeeze=True) + ax.plot(x, y, label="data") + ax.plot(x, val_gauss, linestyle="--", color="red", label="fit") + ax.text( + 0.05, + 0.95, + f"FWHM = {fwhm:.2f}", + transform=ax.transAxes, + verticalalignment="top", + horizontalalignment="left" + ) + + ax.legend() + ax.set_xlabel("X-axis Position") + ax.set_ylabel("Intensity") + ax.set_title(f"{scan_name}-{roi_num}") + ax.grid(True) plt.tight_layout() return fig -def save_xrfdata( - img_h5_path: str, +def save_xrf_line_scan( + mda_path: str, output_dir: str, - cmap: str = "inferno", - elms: list[str] = None, - vmax_th: float = 99, - vmin: float = 0, - return_image_array: bool = False + roi_num: int, + y_threshold: float = 0.0, + return_line_array: bool = False ) -> str | None: + """ - Save the XRF data in png format. + Save the XRF line scan data in png format. Parameters ---------- - img_h5_path : str - The path to the h5 file. + mda_path : str + The path to the MDA file. output_dir : str The path to the output directory. - cmap : str - The colormap to use. - elms : list - The elements to plot. - vmax_th : float - The threshold for the maximum percentile of the colorbar. - vmin : float - The minimum value of the colorbar. - return_image_array : bool - If True, an numpy array of the image will be returned - in addition to the path to the saved image. + roi_num : int + The ROI number of the elements of interest. + y_threshold : float + The threshold for the Gaussian fit. + return_line_array : bool + If True, the line array will be returned. Returns ------- str | None The path to the saved image. """ - data = load_h5(img_h5_path) - if data: - data_arr = data["ROI_arr"] - data_ch = data["ROI_ch"] - xaxis = data["x_axis"] - yaxis = data["y_axis"] - if elms: - plot_elms = elms - else: - plot_elms = data_ch - for e in plot_elms: - plotarr = data_arr[data_ch.index(e)] - vmax = np.nanpercentile(plotarr, vmax_th) - fig = plot_xrfdata(plotarr, xaxis, yaxis, data["scan"], e, cmap, vmax, vmin) - fname = f"{output_dir}/{data['scan']}_{e}.png" - fig.savefig(fname) - plt.close(fig) - logger.info(f"Image saved to {fname}") - if return_image_array: - return fname, plotarr - else: - return fname + try: + roi_data, position_data = get_roi_from_mda(mda_path, roi_num) + except Exception as e: + logger.error(f"Failed to get ROI or position data from MDA file {mda_path}: {e}") + return None + + if any(data is None for data in [roi_data, position_data]): + logger.error(f"Failed to get ROI or position data from MDA file {mda_path}") + return None else: - logger.error(f"The XRF h5 file {img_h5_path} not found") - if return_image_array: - return None, None + order = np.argsort(position_data) + x = position_data[order] + y = roi_data[order] + + # Fit a Gaussian to the data + a, mu, sigma, c = eaa.maths.fit_gaussian_1d(x, y, y_threshold=y_threshold) + val_gauss = eaa.maths.gaussian_1d(x, a, mu, sigma, c) + fwhm = 2.35 * sigma + + # Plot the data and the fit + scan_name = os.path.basename(mda_path) + fig = plot_xrf_line_scan(x, y, val_gauss, fwhm, scan_name, roi_num) + sc = scan_name.replace(".mda", "") + fname = f"{output_dir}/{sc}_ROI{roi_num}.png" + fig.savefig(fname) + plt.close(fig) + logger.info(f"Image saved to {fname}") + if return_line_array: + return fname, [x, y, val_gauss, fwhm] else: - return None + return fname From d6d323f36163c7cb3fa1aa85a7bf5b6b06c45c26 Mon Sep 17 00:00:00 2001 From: Ming Du Date: Tue, 18 Nov 2025 10:50:28 -0600 Subject: [PATCH 02/21] FIX: add tool specs and annotations --- src/eaa/tools/base.py | 7 +++- src/eaa/tools/imaging/aps_mic/acquisition.py | 17 +++++++- src/eaa/tools/imaging/aps_mic/param_tuning.py | 40 ++++++++++++++++--- src/eaa/tools/imaging/aps_mic/scan_control.py | 8 +++- 4 files changed, 62 insertions(+), 10 deletions(-) diff --git a/src/eaa/tools/base.py b/src/eaa/tools/base.py index a8a4c2c..a8cd6b4 100644 --- a/src/eaa/tools/base.py +++ b/src/eaa/tools/base.py @@ -207,7 +207,12 @@ def resolve_json_type(py_type): json_type = resolve_json_type(type_hints[name]) description = f"{name} parameter" if len(get_args(sig.parameters[name].annotation)) > 0: - description = get_args(sig.parameters[name].annotation)[1] + try: + description = get_args(sig.parameters[name].annotation)[1] + except IndexError: + raise ValueError( + f"The description of parameter {name} is not provided in function {func.__name__}." + ) properties[name] = {**json_type, "description": description} if param.default == inspect.Parameter.empty: required.append(name) diff --git a/src/eaa/tools/imaging/aps_mic/acquisition.py b/src/eaa/tools/imaging/aps_mic/acquisition.py index ac52160..d1454b0 100644 --- a/src/eaa/tools/imaging/aps_mic/acquisition.py +++ b/src/eaa/tools/imaging/aps_mic/acquisition.py @@ -2,8 +2,8 @@ import logging import os -from eaa.tools.imaging.acquisition import AcquireImage -from eaa.tools.imaging.param_tuning import SetParameters + +from eaa.tools.base import ToolReturnType, ExposedToolSpec from eaa.tools.imaging.aps_mic.util import ( process_xrfdata, save_xrf_line_scan, @@ -87,6 +87,19 @@ def __init__( self.allowable_z_range = allowable_z_range super().__init__(*args, require_approval=require_approval, **kwargs) + self.exposed_tools = [ + ExposedToolSpec( + name="acquire_image", + function=self.acquire_image, + return_type=ToolReturnType.IMAGE_PATH, + ), + ExposedToolSpec( + name="acquire_line_scan", + function=self.acquire_line_scan, + return_type=ToolReturnType.IMAGE_PATH, + ), + ] + def acquire_image( self, width: Annotated[float, "The width of the scan area in microns"] = 0, diff --git a/src/eaa/tools/imaging/aps_mic/param_tuning.py b/src/eaa/tools/imaging/aps_mic/param_tuning.py index fdb2dcd..70bb8c3 100644 --- a/src/eaa/tools/imaging/aps_mic/param_tuning.py +++ b/src/eaa/tools/imaging/aps_mic/param_tuning.py @@ -1,7 +1,9 @@ +from typing import Annotated, Optional, Tuple +import logging + +from eaa.tools.base import ToolReturnType, ExposedToolSpec from eaa.tools.imaging.param_tuning import SetParameters from eaa.tools.imaging.aps_mic.util import validate_position_in_range -from typing import Optional, Tuple -import logging logger = logging.getLogger(__name__) @@ -45,10 +47,38 @@ def __init__( # self.parameter_names = parameter_names # self.parameter_ranges = parameter_ranges - super().__init__(parameter_names, parameter_ranges, *args, **kwargs) + super().__init__( + *args, + parameter_names=parameter_names, + parameter_ranges=parameter_ranges, + require_approval=require_approval, + **kwargs + ) - def set_parameters(self, parameters: list[float]) -> str: - """Set the sample z motor position of the imaging system.""" + self.exposed_tools = [ + ExposedToolSpec( + name="set_parameters", + function=self.set_parameters, + return_type=ToolReturnType.TEXT, + ) + ] + + def set_parameters( + self, + parameters: Annotated[ + list[float], + "The parameters to set the optics to. For this function, " + "the list should only contain one element giving the z position." + ] + ) -> str: + """Set the sample z motor position of the imaging system. + + Parameters + ---------- + parameters: list[float] + The parameters to set the optics to. For this function, + the list should only contain one element giving the z position. + """ if self.RE is None: raise ValueError("RunEngine is not set") if self.samz_motor is None: diff --git a/src/eaa/tools/imaging/aps_mic/scan_control.py b/src/eaa/tools/imaging/aps_mic/scan_control.py index f397a8d..5790367 100644 --- a/src/eaa/tools/imaging/aps_mic/scan_control.py +++ b/src/eaa/tools/imaging/aps_mic/scan_control.py @@ -2,11 +2,12 @@ # ruff: noqa: F403 +from eaa.tools.base import BaseTool from eaa.tools.imaging.aps_mic.acquisition import BlueSkyAcquireImage from eaa.tools.imaging.aps_mic.param_tuning import BlueskyParameterTuning -class BlueskyScanControl(BlueSkyAcquireImage, BlueskyParameterTuning): +class BlueskyScanControl(BaseTool): from typing import Callable from ophyd import EpicsMotor @@ -17,7 +18,9 @@ class BlueskyScanControl(BlueSkyAcquireImage, BlueskyParameterTuning): acquire_image_tool: BlueSkyAcquireImage = None param_tuning_tool: BlueskyParameterTuning = None - def __init__(self): + def __init__(self, require_approval: bool = False, *args, **kwargs): + super().__init__(require_approval=require_approval, *args, **kwargs) + try: from s2idd_uprobe.startup import RE, oregistry, fly2d_scanrecord, step1d_scanrecord, bps except ImportError: @@ -36,3 +39,4 @@ def __init__(self): self.param_tuning_tool.RE = RE self.param_tuning_tool.samz_motor = oregistry["samz"] + self.exposed_tools = self.acquire_image_tool.exposed_tools + self.param_tuning_tool.exposed_tools From d8971cef282e082412ae4505764a056bde6a088e Mon Sep 17 00:00:00 2001 From: Ming Du Date: Tue, 18 Nov 2025 10:50:44 -0600 Subject: [PATCH 03/21] BUILD: add mic-vis --- pyproject.toml | 1 + uv.lock | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 15e329c..157698d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dynamic = ["version"] aps_mic = [ "bluesky", "mic-instrument@git+https://github.com/BCDA-APS/bluesky-mic.git", + "mic-vis@git+https://github.com/grace227/mic-vis", "h5py", ] asksage = [ diff --git a/uv.lock b/uv.lock index cb02248..a393cab 100644 --- a/uv.lock +++ b/uv.lock @@ -1168,6 +1168,7 @@ aps-mic = [ { name = "bluesky" }, { name = "h5py" }, { name = "mic-instrument" }, + { name = "mic-vis" }, ] asksage = [ { name = "asksageclient" }, @@ -1194,6 +1195,7 @@ requires-dist = [ { name = "h5py", marker = "extra == 'aps-mic'" }, { name = "matplotlib" }, { name = "mic-instrument", marker = "extra == 'aps-mic'", git = "https://github.com/BCDA-APS/bluesky-mic.git" }, + { name = "mic-vis", marker = "extra == 'aps-mic'", git = "https://github.com/grace227/mic-vis" }, { name = "numpy" }, { name = "openai" }, { name = "psycopg", extras = ["binary"], marker = "extra == 'postgresql-vector-store'", specifier = ">=3.2.10" }, @@ -2658,6 +2660,17 @@ dependencies = [ { name = "apsbits" }, ] +[[package]] +name = "mic-vis" +version = "0.1.0" +source = { git = "https://github.com/grace227/mic-vis#51e95c07537ae10f4c7e1a002eeb9929b592d12a" } +dependencies = [ + { name = "h5py" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas" }, +] + [[package]] name = "mistune" version = "3.1.3" From 177993821f2594e1e8bfc14f757fbe27806ab782 Mon Sep 17 00:00:00 2001 From: Ming Du Date: Tue, 18 Nov 2025 10:58:14 -0600 Subject: [PATCH 04/21] CHORE: cleanup --- src/eaa/tools/imaging/aps_mic/param_tuning.py | 4 ++-- src/eaa/tools/imaging/aps_mic/scan_control.py | 6 +++--- src/eaa/tools/imaging/aps_mic/util.py | 1 - src/eaa/tools/imaging/param_tuning.py | 16 ---------------- 4 files changed, 5 insertions(+), 22 deletions(-) diff --git a/src/eaa/tools/imaging/aps_mic/param_tuning.py b/src/eaa/tools/imaging/aps_mic/param_tuning.py index 70bb8c3..cbdda93 100644 --- a/src/eaa/tools/imaging/aps_mic/param_tuning.py +++ b/src/eaa/tools/imaging/aps_mic/param_tuning.py @@ -8,14 +8,14 @@ logger = logging.getLogger(__name__) -class BlueskyParameterTuning(SetParameters): +class BlueskySetParameters(SetParameters): from bluesky.run_engine import RunEngine from typing import Callable from ophyd import EpicsMotor import bluesky.plan_stubs as bps - name: str = "bluesky_parameter_tuning" + name: str = "bluesky_set_parameters" samz_motor: EpicsMotor = None RE: RunEngine = None allowable_z_range: Optional[Tuple[float, float]] = None diff --git a/src/eaa/tools/imaging/aps_mic/scan_control.py b/src/eaa/tools/imaging/aps_mic/scan_control.py index 5790367..15f027f 100644 --- a/src/eaa/tools/imaging/aps_mic/scan_control.py +++ b/src/eaa/tools/imaging/aps_mic/scan_control.py @@ -4,7 +4,7 @@ from eaa.tools.base import BaseTool from eaa.tools.imaging.aps_mic.acquisition import BlueSkyAcquireImage -from eaa.tools.imaging.aps_mic.param_tuning import BlueskyParameterTuning +from eaa.tools.imaging.aps_mic.param_tuning import BlueskySetParameters class BlueskyScanControl(BaseTool): @@ -16,7 +16,7 @@ class BlueskyScanControl(BaseTool): scan2d_plan: Callable = None scan1d_plan: Callable = None acquire_image_tool: BlueSkyAcquireImage = None - param_tuning_tool: BlueskyParameterTuning = None + param_tuning_tool: BlueskySetParameters = None def __init__(self, require_approval: bool = False, *args, **kwargs): super().__init__(require_approval=require_approval, *args, **kwargs) @@ -35,7 +35,7 @@ def __init__(self, require_approval: bool = False, *args, **kwargs): self.acquire_image_tool.scan2d_plan = fly2d_scanrecord self.acquire_image_tool.scan1d_plan = step1d_scanrecord - self.param_tuning_tool = BlueskyParameterTuning() + self.param_tuning_tool = BlueskySetParameters() self.param_tuning_tool.RE = RE self.param_tuning_tool.samz_motor = oregistry["samz"] diff --git a/src/eaa/tools/imaging/aps_mic/util.py b/src/eaa/tools/imaging/aps_mic/util.py index 4a7ee11..162e8ed 100644 --- a/src/eaa/tools/imaging/aps_mic/util.py +++ b/src/eaa/tools/imaging/aps_mic/util.py @@ -4,7 +4,6 @@ import subprocess import os -import h5py import numpy as np import matplotlib.pyplot as plt import logging diff --git a/src/eaa/tools/imaging/param_tuning.py b/src/eaa/tools/imaging/param_tuning.py index 4429b12..96f3685 100644 --- a/src/eaa/tools/imaging/param_tuning.py +++ b/src/eaa/tools/imaging/param_tuning.py @@ -107,22 +107,6 @@ def update_parameter_history(self, parameters: list[float] | dict[str, float]): self.parameter_history[self.parameter_names[i]].append(val) -class BlueSkySetParameters(SetParameters): - - name: str = "bluesky_tune_optics_parameters" - - def __init__( - self, - *args, - require_approval: bool = False, - **kwargs, - ): - super().__init__(*args, require_approval=require_approval, **kwargs) - - def set_parameters(*args, **kwargs): - raise NotImplementedError - - class SimulatedSetParameters(SetParameters): name: str = "simulated_tune_optics_parameters" From bcb2f747120e699144a561d7f54343873c203678 Mon Sep 17 00:00:00 2001 From: grace227 Date: Sun, 23 Nov 2025 13:18:54 -0600 Subject: [PATCH 05/21] FIX: fix import --- src/eaa/tools/imaging/aps_mic/acquisition.py | 4 +- src/eaa/tools/imaging/aps_mic/util.py | 160 ++++++++++++++++++- 2 files changed, 159 insertions(+), 5 deletions(-) diff --git a/src/eaa/tools/imaging/aps_mic/acquisition.py b/src/eaa/tools/imaging/aps_mic/acquisition.py index d1454b0..c92a0bd 100644 --- a/src/eaa/tools/imaging/aps_mic/acquisition.py +++ b/src/eaa/tools/imaging/aps_mic/acquisition.py @@ -2,12 +2,12 @@ import logging import os - from eaa.tools.base import ToolReturnType, ExposedToolSpec from eaa.tools.imaging.aps_mic.util import ( process_xrfdata, save_xrf_line_scan, validate_position_in_range, + save_xrfdata ) from eaa.util import wait_for_file # from eaa.tools.imaging.aps_mic.bluesky_init import BlueskyScanControl @@ -220,8 +220,6 @@ def acquire_line_scan( stepsize_x: float The scan step size in the x direction, i.e., the distance between two adjacent pixels in the x direction in microns. - sample_z: float - The sample z position in millimeters. Returns ------- diff --git a/src/eaa/tools/imaging/aps_mic/util.py b/src/eaa/tools/imaging/aps_mic/util.py index 162e8ed..893312c 100644 --- a/src/eaa/tools/imaging/aps_mic/util.py +++ b/src/eaa/tools/imaging/aps_mic/util.py @@ -3,11 +3,14 @@ """ import subprocess +import logging +from typing import Optional, Tuple import os + import numpy as np import matplotlib.pyplot as plt -import logging -from typing import Optional, Tuple +import h5py + from mic_vis.s2idd.mda import get_roi_from_mda import eaa.maths @@ -230,3 +233,156 @@ def save_xrf_line_scan( return fname, [x, y, val_gauss, fwhm] else: return fname + + +def load_h5(img_h5_path, fit_type=["NNLS", "ROI"], fsizelim=1e3) -> dict: + """ + Load the XRF data from the h5 file. + + Parameters + ---------- + img_h5_path : str + The path to the h5 file. + fit_type : list + The type of fitting. + fsizelim : float + The size limit of the h5 file, only load + the h5 file larger than this size. + + Returns + ------- + dict + The data from the h5 file. + """ + data = {} + fsize = os.path.getsize(img_h5_path) + if fsize > fsizelim: + with h5py.File(img_h5_path, "r") as f: + data.update({"scan": os.path.basename(img_h5_path)}) + data.update({"x_axis": f["MAPS/Scan/x_axis"][:]}) + data.update({"y_axis": f["MAPS/Scan/y_axis"][:]}) + for t in fit_type: + d = f[f"MAPS/XRF_Analyzed/{t}/Counts_Per_Sec"][:] + d_ch = f[f"MAPS/XRF_Analyzed/{t}/Channel_Names"][:].astype(str).tolist() + scaler_names = f["MAPS/Scalers/Names"][:].astype(str).tolist() + scaler_values = f["MAPS/Scalers/Values"][:] + + data.update({f"{t}_arr": d}) + data.update({f"{t}_ch": d_ch}) + data.update({f"{t}_scaler_names": scaler_names}) + data.update({f"{t}_scaler_values": scaler_values}) + + return data + else: + print(f"The XRF h5 file {img_h5_path} not found") + return None + + +def plot_xrfdata(plotarr, xaxis, yaxis, scan_name, elm_name, cmap, vmax, vmin): + """ + Plot the XRF data. + + Parameters + ---------- + plotarr : numpy.ndarray + The array to plot. + xaxis : numpy.ndarray + The x-axis data. + yaxis : numpy.ndarray + The y-axis data. + scan_name : str + The name of the scan. + elm_name : str + The name of the element. + cmap : str + The colormap to use. + vmax : float + The maximum value of the colorbar. + vmin : float + The minimum value of the colorbar. + + Returns + ------- + matplotlib.figure.Figure + The figure object. + """ + fig, ax = plt.subplots(figsize=(5, 5)) + ax.imshow(plotarr, cmap=cmap, vmax=vmax, vmin=vmin) + ax.set_title(f"{scan_name} {elm_name}") + + # Show only 5 ticks for both x- and y- axes + xticks = np.linspace(0, len(xaxis) - 1, 5, dtype=int) + yticks = np.linspace(0, len(yaxis) - 1, 5, dtype=int) + ax.set_xticks(xticks) + ax.set_yticks(yticks) + ax.set_xticklabels([np.round(xaxis[i], 2) for i in xticks]) + ax.set_yticklabels([np.round(yaxis[i], 2) for i in yticks]) + ax.tick_params(axis="both", which="major", labelsize=12) + plt.tight_layout() + return fig + + +def save_xrfdata( + img_h5_path: str, + output_dir: str, + cmap: str = "inferno", + elms: list[str] = None, + vmax_th: float = 99, + vmin: float = 0, + return_image_array: bool = False +) -> str | None: + """ + Save the XRF data in png format. + + Parameters + ---------- + img_h5_path : str + The path to the h5 file. + output_dir : str + The path to the output directory. + cmap : str + The colormap to use. + elms : list + The elements to plot. + vmax_th : float + The threshold for the maximum percentile of the colorbar. + vmin : float + The minimum value of the colorbar. + return_image_array : bool + If True, an numpy array of the image will be returned + in addition to the path to the saved image. + + Returns + ------- + str | None + The path to the saved image. + """ + data = load_h5(img_h5_path) + if data: + data_arr = data["ROI_arr"] + data_ch = data["ROI_ch"] + xaxis = data["x_axis"] + yaxis = data["y_axis"] + if elms: + plot_elms = elms + else: + plot_elms = data_ch + + for e in plot_elms: + plotarr = data_arr[data_ch.index(e)] + vmax = np.nanpercentile(plotarr, vmax_th) + fig = plot_xrfdata(plotarr, xaxis, yaxis, data["scan"], e, cmap, vmax, vmin) + fname = f"{output_dir}/{data['scan']}_{e}.png" + fig.savefig(fname) + plt.close(fig) + logger.info(f"Image saved to {fname}") + if return_image_array: + return fname, plotarr + else: + return fname + else: + logger.error(f"The XRF h5 file {img_h5_path} not found") + if return_image_array: + return None, None + else: + return None \ No newline at end of file From 138bd7c0beda1983af373b1550bc0e5b7190443a Mon Sep 17 00:00:00 2001 From: grace227 Date: Sun, 23 Nov 2025 15:21:33 -0600 Subject: [PATCH 06/21] FIX: fix parameter validation --- src/eaa/tools/imaging/aps_mic/param_tuning.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/eaa/tools/imaging/aps_mic/param_tuning.py b/src/eaa/tools/imaging/aps_mic/param_tuning.py index cbdda93..8f70d13 100644 --- a/src/eaa/tools/imaging/aps_mic/param_tuning.py +++ b/src/eaa/tools/imaging/aps_mic/param_tuning.py @@ -86,7 +86,7 @@ def set_parameters( if self.parameter_ranges is not None: validate_position_in_range( parameters[0], - (self.parameter_ranges[0], self.parameter_ranges[1]), + (self.parameter_ranges[0][0], self.parameter_ranges[1][0]), "z") self.RE(self.bps.mv(self.samz_motor, parameters[0])) msg = f"Move sample z motor to position: {parameters[0]}" From e920df0c4c7414da11cf80d9bf3bd5b5f60344cb Mon Sep 17 00:00:00 2001 From: grace227 Date: Sun, 23 Nov 2025 15:21:46 -0600 Subject: [PATCH 07/21] FIX: handle Gaussian fit failure --- src/eaa/tools/imaging/aps_mic/util.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/eaa/tools/imaging/aps_mic/util.py b/src/eaa/tools/imaging/aps_mic/util.py index 893312c..3b24871 100644 --- a/src/eaa/tools/imaging/aps_mic/util.py +++ b/src/eaa/tools/imaging/aps_mic/util.py @@ -153,7 +153,8 @@ def plot_xrf_line_scan(x, y, val_gauss, fwhm, scan_name, roi_num): """ fig, ax = plt.subplots(1, 1, squeeze=True) ax.plot(x, y, label="data") - ax.plot(x, val_gauss, linestyle="--", color="red", label="fit") + if val_gauss is not None: + ax.plot(x, val_gauss, linestyle="--", color="red", label="fit") ax.text( 0.05, 0.95, @@ -217,9 +218,14 @@ def save_xrf_line_scan( y = roi_data[order] # Fit a Gaussian to the data - a, mu, sigma, c = eaa.maths.fit_gaussian_1d(x, y, y_threshold=y_threshold) - val_gauss = eaa.maths.gaussian_1d(x, a, mu, sigma, c) - fwhm = 2.35 * sigma + try: + a, mu, sigma, c = eaa.maths.fit_gaussian_1d(x, y, y_threshold=y_threshold) + val_gauss = eaa.maths.gaussian_1d(x, a, mu, sigma, c) + fwhm = 2.35 * sigma + except RuntimeError as e: + logger.error(f"Failed to fit Gaussian to data: {e}") + val_gauss = None + fwhm = np.nan # Plot the data and the fit scan_name = os.path.basename(mda_path) From 9599cf927a58853c4b80221f05212b6f1c38a10b Mon Sep 17 00:00:00 2001 From: grace227 Date: Sun, 23 Nov 2025 15:22:07 -0600 Subject: [PATCH 08/21] FEAT: add y position to line scan tool --- src/eaa/tools/imaging/aps_mic/acquisition.py | 34 +++++++++++++++++-- src/eaa/tools/imaging/aps_mic/scan_control.py | 1 + 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/eaa/tools/imaging/aps_mic/acquisition.py b/src/eaa/tools/imaging/aps_mic/acquisition.py index c92a0bd..2e600a2 100644 --- a/src/eaa/tools/imaging/aps_mic/acquisition.py +++ b/src/eaa/tools/imaging/aps_mic/acquisition.py @@ -22,12 +22,16 @@ class BlueSkyAcquireImage(AcquireImage): from typing import Callable from mic_common.devices.save_data import SaveDataMic from mic_vis.s2idd.xrf_eaa import save_xrfdata + from ophyd import EpicsMotor + import bluesky.plan_stubs as bps name: str = "bluesky_acquire_image" RE: RunEngine = None savedata: SaveDataMic = None scan2d_plan: Callable = None scan1d_plan: Callable = None + samy_motor: EpicsMotor = None + bps: Callable = bps def __init__( self, @@ -206,10 +210,11 @@ def acquire_line_scan( self, width: Annotated[float, "The width of the scan area in microns"] = 0, x_center: Annotated[float, "The center of the scan area in the x direction in microns"] = None, + y_center: Annotated[float, "The center of the scan area in the y direction in microns"] = None, stepsize_x: Annotated[float, "The scan step size in the x direction, i.e., the distance between two adjacent pixels in the x direction in microns"] = 0, )->Annotated[str, "The path to the plot of the line scan."]: - """Acquire a line scan of a given width at a given center position. + """Acquire a horizontal line scan of a given width at a given center position. Parameters ---------- @@ -217,6 +222,8 @@ def acquire_line_scan( The width of the scan area in microns. x_center: float The center of the scan area in the x direction in microns. + y_center: float + The center of the scan area in the y direction in microns. stepsize_x: float The scan step size in the x direction, i.e., the distance between two adjacent pixels in the x direction in microns. @@ -227,6 +234,8 @@ def acquire_line_scan( The path of the plot of the line scan saved in hard drive. """ + self.set_motor_y(y_center) + start_x = x_center - width/2 end_x = x_center + width/2 self.update_line_scan_call_history(start_x=start_x, end_x=end_x, step=stepsize_x, start_y=None, end_y=None) @@ -263,7 +272,7 @@ def acquire_line_scan( if not os.path.exists(png_output_dir): os.makedirs(png_output_dir) - img_path, img_arr = save_xrf_line_scan( + img_path, _ = save_xrf_line_scan( mda_file_path, png_output_dir, roi_num=self.xrf_roi_num, return_line_array=True ) @@ -282,3 +291,24 @@ def acquire_line_scan( logger.error(f"Error acquiring line scan: {e}") raise e + def set_motor_y( + self, + value: float + ) -> str: + """Set the sample y motor position of the imaging system. + + Parameters + ---------- + value: float + The value to set the sample y motor to. + """ + if self.RE is None: + raise ValueError("RunEngine is not set") + if self.samy_motor is None: + raise ValueError("samz_motor is not set") + + validate_position_in_range(value, self.allowable_y_range, "y") + self.RE(self.bps.mv(self.samy_motor, value)) + msg = f"Move sample y motor to position: {value}" + logger.info(msg) + return msg diff --git a/src/eaa/tools/imaging/aps_mic/scan_control.py b/src/eaa/tools/imaging/aps_mic/scan_control.py index 15f027f..38495a1 100644 --- a/src/eaa/tools/imaging/aps_mic/scan_control.py +++ b/src/eaa/tools/imaging/aps_mic/scan_control.py @@ -34,6 +34,7 @@ def __init__(self, require_approval: bool = False, *args, **kwargs): self.acquire_image_tool.savedata = oregistry["savedata"] self.acquire_image_tool.scan2d_plan = fly2d_scanrecord self.acquire_image_tool.scan1d_plan = step1d_scanrecord + self.acquire_image_tool.samy_motor = oregistry["samy"] self.param_tuning_tool = BlueskySetParameters() self.param_tuning_tool.RE = RE From 24f01c4f8e3aee9b4ab8173233a0d2530694644c Mon Sep 17 00:00:00 2001 From: grace227 Date: Sun, 23 Nov 2025 17:54:38 -0600 Subject: [PATCH 09/21] FEAT: Bluesky acquisition tool plot in log scale --- src/eaa/tools/imaging/aps_mic/acquisition.py | 9 +++++++-- src/eaa/tools/imaging/aps_mic/util.py | 17 +++++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/eaa/tools/imaging/aps_mic/acquisition.py b/src/eaa/tools/imaging/aps_mic/acquisition.py index 2e600a2..f5aa1ec 100644 --- a/src/eaa/tools/imaging/aps_mic/acquisition.py +++ b/src/eaa/tools/imaging/aps_mic/acquisition.py @@ -46,6 +46,7 @@ def __init__( allowable_x_range: Optional[Tuple[float, float]] = None, allowable_y_range: Optional[Tuple[float, float]] = None, allowable_z_range: Optional[Tuple[float, float]] = None, + plot_image_in_log_scale: bool = False, require_approval: bool = False, *args, **kwargs ): @@ -71,7 +72,9 @@ def __init__( The allowable range of scan center position in the x direction. allowable_y_range: Optional[Tuple[float, float]], optional The allowable range of scan center position in the y direction. - + plot_image_in_log_scale: bool, optional + Whether to plot the image in log scale. + Raises ------ ImportError @@ -89,6 +92,7 @@ def __init__( self.allowable_x_range = allowable_x_range self.allowable_y_range = allowable_y_range self.allowable_z_range = allowable_z_range + self.plot_image_in_log_scale = plot_image_in_log_scale super().__init__(*args, require_approval=require_approval, **kwargs) self.exposed_tools = [ @@ -188,7 +192,8 @@ def acquire_image( f"{current_mda_file}.h50") img_path, img_arr = save_xrfdata( - img_h5_path, png_output_dir, elms=self.xrf_elms, return_image_array=True + img_h5_path, png_output_dir, elms=self.xrf_elms, return_image_array=True, + plot_in_log_scale=self.plot_image_in_log_scale ) wait_for_file(img_path, duration=5) diff --git a/src/eaa/tools/imaging/aps_mic/util.py b/src/eaa/tools/imaging/aps_mic/util.py index 3b24871..9aa9d6a 100644 --- a/src/eaa/tools/imaging/aps_mic/util.py +++ b/src/eaa/tools/imaging/aps_mic/util.py @@ -221,7 +221,7 @@ def save_xrf_line_scan( try: a, mu, sigma, c = eaa.maths.fit_gaussian_1d(x, y, y_threshold=y_threshold) val_gauss = eaa.maths.gaussian_1d(x, a, mu, sigma, c) - fwhm = 2.35 * sigma + fwhm = 2.35 * abs(sigma) except RuntimeError as e: logger.error(f"Failed to fit Gaussian to data: {e}") val_gauss = None @@ -284,7 +284,7 @@ def load_h5(img_h5_path, fit_type=["NNLS", "ROI"], fsizelim=1e3) -> dict: return None -def plot_xrfdata(plotarr, xaxis, yaxis, scan_name, elm_name, cmap, vmax, vmin): +def plot_xrfdata(plotarr, xaxis, yaxis, scan_name, elm_name, cmap, vmax, vmin, plot_in_log_scale: bool = False): """ Plot the XRF data. @@ -306,13 +306,19 @@ def plot_xrfdata(plotarr, xaxis, yaxis, scan_name, elm_name, cmap, vmax, vmin): The maximum value of the colorbar. vmin : float The minimum value of the colorbar. - + plot_in_log_scale : bool + Whether to plot the image in log scale. + Returns ------- matplotlib.figure.Figure The figure object. """ fig, ax = plt.subplots(figsize=(5, 5)) + if plot_in_log_scale: + plotarr = np.log10(plotarr + 1) + vmax = np.log10(vmax + 1) + vmin = np.log10(vmin + 1) ax.imshow(plotarr, cmap=cmap, vmax=vmax, vmin=vmin) ax.set_title(f"{scan_name} {elm_name}") @@ -335,6 +341,7 @@ def save_xrfdata( elms: list[str] = None, vmax_th: float = 99, vmin: float = 0, + plot_in_log_scale: bool = False, return_image_array: bool = False ) -> str | None: """ @@ -354,6 +361,8 @@ def save_xrfdata( The threshold for the maximum percentile of the colorbar. vmin : float The minimum value of the colorbar. + plot_in_log_scale : bool + Whether to plot the image in log scale. return_image_array : bool If True, an numpy array of the image will be returned in addition to the path to the saved image. @@ -377,7 +386,7 @@ def save_xrfdata( for e in plot_elms: plotarr = data_arr[data_ch.index(e)] vmax = np.nanpercentile(plotarr, vmax_th) - fig = plot_xrfdata(plotarr, xaxis, yaxis, data["scan"], e, cmap, vmax, vmin) + fig = plot_xrfdata(plotarr, xaxis, yaxis, data["scan"], e, cmap, vmax, vmin, plot_in_log_scale=plot_in_log_scale) fname = f"{output_dir}/{data['scan']}_{e}.png" fig.savefig(fname) plt.close(fig) From 9fbfb2b01c6589dacfe921e9b7e40873bcff4a72 Mon Sep 17 00:00:00 2001 From: grace227 Date: Sun, 23 Nov 2025 17:55:08 -0600 Subject: [PATCH 10/21] FIX: do not deep copy registration tool in focusing TM --- src/eaa/task_managers/tuning/focusing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/eaa/task_managers/tuning/focusing.py b/src/eaa/task_managers/tuning/focusing.py index acea1cf..2b650ea 100644 --- a/src/eaa/task_managers/tuning/focusing.py +++ b/src/eaa/task_managers/tuning/focusing.py @@ -303,7 +303,7 @@ def register_images(self, image_k: np.ndarray, image_km1: np.ndarray) -> np.ndar raise ValueError( "`image_registration_tool` should be provided in the class constructor." ) - registration_tool = copy.deepcopy(self.image_registration_tool) + registration_tool = self.image_registration_tool shift = registration_tool.register_images( image_t=registration_tool.process_image(image_k), image_r=registration_tool.process_image(image_km1), From 92e9ab5f57e4e667bc0a4b0ecbf152c568b0cb06 Mon Sep 17 00:00:00 2001 From: grace227 Date: Sun, 23 Nov 2025 17:55:29 -0600 Subject: [PATCH 11/21] CHORE: tweak prompt and tool docs --- src/eaa/task_managers/tuning/focusing.py | 16 +++++++++------- src/eaa/tools/imaging/aps_mic/acquisition.py | 2 ++ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/eaa/task_managers/tuning/focusing.py b/src/eaa/task_managers/tuning/focusing.py index 2b650ea..6cb9d77 100644 --- a/src/eaa/task_managers/tuning/focusing.py +++ b/src/eaa/task_managers/tuning/focusing.py @@ -227,10 +227,13 @@ def hook_function(image_path: str) -> None: f"drift is {[float(shift[i] + scan_pos_diff[i]) for i in [0, 1]]} (y, x). Use this offset to " f"to adjust the line scan positions by **adding** it to both " f"the x and y coordinates of the start and end points of the previous line scan. " - f"For your reference, the last line scan tool call is {self.acquisition_tool.line_scan_call_history[-1]}." - f"Also use this offset to update the argument when you perform 2D image acquisition " - f"next time. The last 2D image acquisition call is {self.acquisition_tool.image_acquisition_call_history[-1]}." ) + if len(self.acquisition_tool.line_scan_call_history[-1]) > 0: + message += ( + f"For your reference, the last line scan tool call is {self.acquisition_tool.line_scan_call_history[-1]}." + f"Also use this offset to update the argument when you perform 2D image acquisition " + f"next time. The last 2D image acquisition call is {self.acquisition_tool.image_acquisition_call_history[-1]}." + ) self.last_acquisition_count_registered = self.acquisition_tool.counter_acquire_image return [generate_openai_message(content=message, image_path=image_path)] @@ -475,12 +478,11 @@ def run( f"your response to hand over control back to the user.\n\n" f"Important notes:\n\n" f"- Your line scan should cross only one line feature, and you should see " - f"**exactly one peak** in the line scan plot. If there isn't one, or if there " - f"are multiple peaks, or if the Gaussian fit looks bad, check your arguments " + f"**exactly one peak** in the line scan plot. If there isn't one, or if " + f"the Gaussian fit looks bad, check your arguments " f"to the line scan tool and run it again. Make sure your line scan strictly " f"follow the marker in the reference image. Do not trust the FWHM value " - f"in the line plot if there is no peak, if the peak is incomplete, or if " - f"there are multiple peaks!\n" + f"in the line plot if there is no peak, or if the peak is incomplete!\n" f"- The line scan plot should show a complete peak. If the peak is incomplete, " f"adjust the line scan tool's arguments to make it complete.\n" f"- The minimal point of the FWHM is indicated by an inflection of the trend " diff --git a/src/eaa/tools/imaging/aps_mic/acquisition.py b/src/eaa/tools/imaging/aps_mic/acquisition.py index f5aa1ec..8673455 100644 --- a/src/eaa/tools/imaging/aps_mic/acquisition.py +++ b/src/eaa/tools/imaging/aps_mic/acquisition.py @@ -220,6 +220,8 @@ def acquire_line_scan( )->Annotated[str, "The path to the plot of the line scan."]: """Acquire a horizontal line scan of a given width at a given center position. + This function returns a plot of the acquired data, and a Gaussian fit of it. + The FWHM of the Gaussian fit is annotated on the plot. Parameters ---------- From 278ca544050250e59854e542149a171f94958c88 Mon Sep 17 00:00:00 2001 From: grace227 Date: Sun, 23 Nov 2025 21:03:53 -0600 Subject: [PATCH 12/21] FIX: always multiply offset by pixel size in `register_images` --- src/eaa/tools/imaging/registration.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/eaa/tools/imaging/registration.py b/src/eaa/tools/imaging/registration.py index a4eaf5d..906b517 100644 --- a/src/eaa/tools/imaging/registration.py +++ b/src/eaa/tools/imaging/registration.py @@ -210,6 +210,5 @@ def register_images( # Convert the offset from pixel units to physical units. We use psize_r here # since the target image has already been resized to have the same pixel size # as the reference image. - if psize_t != psize_r: - offset = offset * psize_r + offset = offset * psize_r return offset From a252614c774b3e261a87bfa0bd52b05d62d5365b Mon Sep 17 00:00:00 2001 From: grace227 Date: Mon, 24 Nov 2025 14:08:24 -0600 Subject: [PATCH 13/21] FIX: use zp-z instead samz in ZP setting tool --- src/eaa/tools/imaging/aps_mic/param_tuning.py | 12 ++++++------ src/eaa/tools/imaging/aps_mic/scan_control.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/eaa/tools/imaging/aps_mic/param_tuning.py b/src/eaa/tools/imaging/aps_mic/param_tuning.py index 8f70d13..1592688 100644 --- a/src/eaa/tools/imaging/aps_mic/param_tuning.py +++ b/src/eaa/tools/imaging/aps_mic/param_tuning.py @@ -23,8 +23,8 @@ class BlueskySetParameters(SetParameters): def __init__( self, - parameter_names: list[str] = ['sample-z'], - parameter_ranges: list[tuple[float, ...], tuple[float, ...]] = [[-0.5], [0.5]], + parameter_names: list[str] = ["zp-z"], + parameter_ranges: list[tuple[float, ...], tuple[float, ...]] = ((-200,),(-180,)), require_approval: bool = False, *args, **kwargs ): @@ -81,15 +81,15 @@ def set_parameters( """ if self.RE is None: raise ValueError("RunEngine is not set") - if self.samz_motor is None: - raise ValueError("samz_motor is not set") + if self.zp_z_motor is None: + raise ValueError("zp_z_motor is not set") if self.parameter_ranges is not None: validate_position_in_range( parameters[0], (self.parameter_ranges[0][0], self.parameter_ranges[1][0]), "z") - self.RE(self.bps.mv(self.samz_motor, parameters[0])) - msg = f"Move sample z motor to position: {parameters[0]}" + self.RE(self.bps.mv(self.zp_z_motor, parameters[0])) + msg = f"Moved Zone Plate z position to position: {parameters[0]}" logger.info(msg) return msg else: diff --git a/src/eaa/tools/imaging/aps_mic/scan_control.py b/src/eaa/tools/imaging/aps_mic/scan_control.py index 38495a1..a67bbf5 100644 --- a/src/eaa/tools/imaging/aps_mic/scan_control.py +++ b/src/eaa/tools/imaging/aps_mic/scan_control.py @@ -38,6 +38,6 @@ def __init__(self, require_approval: bool = False, *args, **kwargs): self.param_tuning_tool = BlueskySetParameters() self.param_tuning_tool.RE = RE - self.param_tuning_tool.samz_motor = oregistry["samz"] + self.param_tuning_tool.zp_z_motor = oregistry["zp_z"] self.exposed_tools = self.acquire_image_tool.exposed_tools + self.param_tuning_tool.exposed_tools From 096abae729e6b7b17accd87dc107a9d14831c6c2 Mon Sep 17 00:00:00 2001 From: grace227 Date: Mon, 24 Nov 2025 15:03:23 -0600 Subject: [PATCH 14/21] FIX: offset x for Gaussian fitting --- src/eaa/maths.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/eaa/maths.py b/src/eaa/maths.py index 764b603..7632079 100644 --- a/src/eaa/maths.py +++ b/src/eaa/maths.py @@ -47,11 +47,14 @@ def fit_gaussian_1d( Returns ------- tuple[float, float, float] - The amplitude, mean, and standard deviation of the Gaussian. + The amplitude, mean, standard deviation, and constant offset of the Gaussian. """ y_max, y_min = np.max(y), np.min(y) x_max = x[np.argmax(y)] + offset = x_max + x = x - offset mask = y >= y_min + y_threshold * (y_max - y_min) p0 = [y_max - y_min, x_max, np.count_nonzero(mask) / (x[-1] - x[0]) / 2, y_min] popt, _ = scipy.optimize.curve_fit(gaussian_1d, x[mask], y[mask], p0=p0) + popt[1] += offset return tuple(popt) From 60fe1153d5ed55013f3c69f62a3fc4eb15031ceb Mon Sep 17 00:00:00 2001 From: mdw771 Date: Wed, 26 Nov 2025 16:32:32 -0600 Subject: [PATCH 15/21] FIX: fix Gaussian fit --- src/eaa/maths.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/eaa/maths.py b/src/eaa/maths.py index 7632079..031e9d9 100644 --- a/src/eaa/maths.py +++ b/src/eaa/maths.py @@ -53,8 +53,18 @@ def fit_gaussian_1d( x_max = x[np.argmax(y)] offset = x_max x = x - offset + x_max = 0 mask = y >= y_min + y_threshold * (y_max - y_min) - p0 = [y_max - y_min, x_max, np.count_nonzero(mask) / (x[-1] - x[0]) / 2, y_min] + a_guess = y_max - y_min + mu_guess = x_max + x_above_thresh = x[y > y_min + a_guess * 0.2] + if len(x_above_thresh) >= 3: + sigma_guess = (x_above_thresh.max() - x_above_thresh.min()) / 2 + else: + sigma_guess = (x.max() - x.min()) / 2 + c_guess = y_min + p0 = [a_guess, mu_guess, sigma_guess, c_guess] popt, _ = scipy.optimize.curve_fit(gaussian_1d, x[mask], y[mask], p0=p0) popt[1] += offset return tuple(popt) + From 7640084ced7b4af87e1cd507311123f61c5ff732 Mon Sep 17 00:00:00 2001 From: mdw771 Date: Wed, 26 Nov 2025 16:34:24 -0600 Subject: [PATCH 16/21] FEAT: allow showing colorbar in BlueskyAcquisitionTool --- src/eaa/tools/imaging/aps_mic/acquisition.py | 12 +++++++-- src/eaa/tools/imaging/aps_mic/util.py | 28 +++++++++++++++----- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/eaa/tools/imaging/aps_mic/acquisition.py b/src/eaa/tools/imaging/aps_mic/acquisition.py index 8673455..0522c16 100644 --- a/src/eaa/tools/imaging/aps_mic/acquisition.py +++ b/src/eaa/tools/imaging/aps_mic/acquisition.py @@ -47,6 +47,7 @@ def __init__( allowable_y_range: Optional[Tuple[float, float]] = None, allowable_z_range: Optional[Tuple[float, float]] = None, plot_image_in_log_scale: bool = False, + show_colorbar_in_image: bool = False, require_approval: bool = False, *args, **kwargs ): @@ -74,6 +75,8 @@ def __init__( The allowable range of scan center position in the y direction. plot_image_in_log_scale: bool, optional Whether to plot the image in log scale. + show_colorbar_in_image: bool, optional + Whether to show the colorbar in the image. Raises ------ @@ -93,6 +96,7 @@ def __init__( self.allowable_y_range = allowable_y_range self.allowable_z_range = allowable_z_range self.plot_image_in_log_scale = plot_image_in_log_scale + self.show_colorbar_in_image = show_colorbar_in_image super().__init__(*args, require_approval=require_approval, **kwargs) self.exposed_tools = [ @@ -192,8 +196,12 @@ def acquire_image( f"{current_mda_file}.h50") img_path, img_arr = save_xrfdata( - img_h5_path, png_output_dir, elms=self.xrf_elms, return_image_array=True, - plot_in_log_scale=self.plot_image_in_log_scale + img_h5_path, + png_output_dir, + elms=self.xrf_elms, + return_image_array=True, + plot_in_log_scale=self.plot_image_in_log_scale, + show_colorbar_in_image=self.show_colorbar_in_image ) wait_for_file(img_path, duration=5) diff --git a/src/eaa/tools/imaging/aps_mic/util.py b/src/eaa/tools/imaging/aps_mic/util.py index 9aa9d6a..e5fef8c 100644 --- a/src/eaa/tools/imaging/aps_mic/util.py +++ b/src/eaa/tools/imaging/aps_mic/util.py @@ -284,7 +284,11 @@ def load_h5(img_h5_path, fit_type=["NNLS", "ROI"], fsizelim=1e3) -> dict: return None -def plot_xrfdata(plotarr, xaxis, yaxis, scan_name, elm_name, cmap, vmax, vmin, plot_in_log_scale: bool = False): +def plot_xrfdata( + plotarr, xaxis, yaxis, scan_name, elm_name, cmap, vmax, vmin, + plot_in_log_scale: bool = False, + show_colorbar: bool = False +): """ Plot the XRF data. @@ -308,6 +312,8 @@ def plot_xrfdata(plotarr, xaxis, yaxis, scan_name, elm_name, cmap, vmax, vmin, p The minimum value of the colorbar. plot_in_log_scale : bool Whether to plot the image in log scale. + show_colorbar : bool + Whether to show the colorbar in the image. Returns ------- @@ -319,9 +325,12 @@ def plot_xrfdata(plotarr, xaxis, yaxis, scan_name, elm_name, cmap, vmax, vmin, p plotarr = np.log10(plotarr + 1) vmax = np.log10(vmax + 1) vmin = np.log10(vmin + 1) - ax.imshow(plotarr, cmap=cmap, vmax=vmax, vmin=vmin) + im = ax.imshow(plotarr, cmap=cmap, vmax=vmax, vmin=vmin) ax.set_title(f"{scan_name} {elm_name}") - + if show_colorbar: + cbar = fig.colorbar(im) + cbar.set_label("Intensity") + # Show only 5 ticks for both x- and y- axes xticks = np.linspace(0, len(xaxis) - 1, 5, dtype=int) yticks = np.linspace(0, len(yaxis) - 1, 5, dtype=int) @@ -342,7 +351,8 @@ def save_xrfdata( vmax_th: float = 99, vmin: float = 0, plot_in_log_scale: bool = False, - return_image_array: bool = False + return_image_array: bool = False, + show_colorbar_in_image: bool = False ) -> str | None: """ Save the XRF data in png format. @@ -366,7 +376,9 @@ def save_xrfdata( return_image_array : bool If True, an numpy array of the image will be returned in addition to the path to the saved image. - + show_colorbar_in_image : bool + Whether to show the colorbar in the image. + Returns ------- str | None @@ -386,7 +398,11 @@ def save_xrfdata( for e in plot_elms: plotarr = data_arr[data_ch.index(e)] vmax = np.nanpercentile(plotarr, vmax_th) - fig = plot_xrfdata(plotarr, xaxis, yaxis, data["scan"], e, cmap, vmax, vmin, plot_in_log_scale=plot_in_log_scale) + fig = plot_xrfdata( + plotarr, xaxis, yaxis, data["scan"], e, cmap, vmax, vmin, + plot_in_log_scale=plot_in_log_scale, + show_colorbar=show_colorbar_in_image + ) fname = f"{output_dir}/{data['scan']}_{e}.png" fig.savefig(fname) plt.close(fig) From 0ec8bcee75cf87f2187c23d4b9ec89c437d6293d Mon Sep 17 00:00:00 2001 From: mdw771 Date: Wed, 26 Nov 2025 16:36:26 -0600 Subject: [PATCH 17/21] FIX: handle errors in launching task manager from chat --- src/eaa/task_managers/base.py | 162 ++++++++++++++++++++-------------- 1 file changed, 96 insertions(+), 66 deletions(-) diff --git a/src/eaa/task_managers/base.py b/src/eaa/task_managers/base.py index dc80a5d..df6078e 100644 --- a/src/eaa/task_managers/base.py +++ b/src/eaa/task_managers/base.py @@ -441,6 +441,8 @@ def launch_task_manager(self, task_request: str) -> None: f"tools:\n{tool_catalog_json}\n" f"User request: {request_text}" ) + # Escape image tag + parsing_prompt = parsing_prompt.replace(" None: context=local_context, return_outgoing_message=True, ) - self.update_message_history(outgoing, update_context=False, update_full_history=True) self.update_message_history(response, update_context=False, update_full_history=True) local_context.append(outgoing) local_context.append(response) @@ -560,6 +561,12 @@ def launch_task_manager(self, task_request: str) -> None: init_kwargs[key] = value try: + log_message = generate_openai_message( + content=f"Instantiating task manager '{manager_name}' with init_kwargs: {init_kwargs}", + role="system", + ) + self.update_message_history(log_message, update_context=True, update_full_history=True) + print_message(log_message) sub_manager = manager_class(**init_kwargs) except Exception as exc: # noqa: BLE001 - surface configuration errors logger.exception("Failed to instantiate task manager '%s'", manager_name) @@ -577,6 +584,16 @@ def launch_task_manager(self, task_request: str) -> None: result = sub_manager.run_conversation() else: method_callable = getattr(sub_manager, resolved_method_name) + log_message = generate_openai_message( + content=f"Running method '{resolved_method_name}' with method_kwargs: {method_kwargs}. Proceed? (yes/no)", + role="system", + ) + proceed = self.get_user_input( + prompt=log_message["content"], + display_prompt_in_webui=bool(self.message_db_conn), + ) + if proceed.strip().lower() != "yes": + return result = method_callable(**method_kwargs) last_of_sub_manager_context = sub_manager.context[-1] if len(sub_manager.context) > 0 else None except Exception as exc: # noqa: BLE001 @@ -625,77 +642,90 @@ def run_conversation( """ response = None while True: - if response is None or (response is not None and not has_tool_call(response)): - message = self.get_user_input( - prompt=( - "Enter a message (/exit: exit; /return: return to upper level task; " - "/help: show command help): " + try: + if response is None or (response is not None and not has_tool_call(response)): + message = self.get_user_input( + prompt=( + "Enter a message (/exit: exit; /return: return to upper level task; " + "/help: show command help): " + ) ) - ) - stripped_message = message.strip() - command, _, remainder = stripped_message.partition(" ") - command_lower = command.lower() + stripped_message = message.strip() + command, _, remainder = stripped_message.partition(" ") + command_lower = command.lower() - if command_lower == "/exit" and remainder == "": - break - elif command_lower == "/return" and remainder == "": - return - elif command_lower == "/monitor": - if len(remainder.strip()) == 0: - logger.info("Monitoring command requires a task description.") - else: - self.enter_monitoring_mode(remainder.strip()) - continue - elif command_lower == "/subtask": - self.launch_task_manager(remainder.strip()) - continue - elif command_lower == "/help" and remainder == "": - self.display_command_help() - continue - - # Send message and get response - response, outgoing_message = self.agent.receive( - message, - context=self.context, - return_outgoing_message=True + if command_lower == "/exit" and remainder == "": + break + elif command_lower == "/return" and remainder == "": + return + elif command_lower == "/monitor": + if len(remainder.strip()) == 0: + logger.info("Monitoring command requires a task description.") + else: + self.enter_monitoring_mode(remainder.strip()) + continue + elif command_lower == "/subtask": + self.launch_task_manager(remainder.strip()) + continue + elif command_lower == "/help" and remainder == "": + self.display_command_help() + continue + + # Send message and get response + response, outgoing_message = self.agent.receive( + message, + context=self.context, + return_outgoing_message=True + ) + # If message DB is used, user input should come from WebUI which writes + # to the DB, so we don't update DB again. + self.update_message_history( + outgoing_message, + update_context=True, + update_full_history=True, + update_db=(self.message_db_conn is None) + ) + self.update_message_history(response, update_context=True, update_full_history=True) + + # Handle tool calls + tool_responses, tool_response_types = self.agent.handle_tool_call(response, return_tool_return_types=True) + for tool_response, tool_response_type in zip(tool_responses, tool_response_types): + print_message(tool_response) + self.update_message_history(tool_response, update_context=True, update_full_history=True) + + if len(tool_responses) >= 1: + for tool_response, tool_response_type in zip(tool_responses, tool_response_types): + # If the tool returns an image path, load the image and send it to + # the assistant in a follow-up message as user. + if tool_response_type == ToolReturnType.IMAGE_PATH: + image_path = tool_response["content"] + image_message = generate_openai_message( + content="Here is the image the tool returned.", + image_path=image_path, + role="user", + ) + self.update_message_history( + image_message, update_context=store_all_images_in_context, update_full_history=True + ) + # Send tool responses stored in the context + response = self.agent.receive( + message=None, + context=self.context, + return_outgoing_message=False + ) + self.update_message_history(response, update_context=True, update_full_history=True) + except KeyboardInterrupt: + self.context = complete_unresponded_tool_calls(self.context) + response = generate_openai_message( + content="Workflow interrupted by keyboard interrupt. TERMINATE", + role="system" ) - # If message DB is used, user input should come from WebUI which writes - # to the DB, so we don't update DB again. self.update_message_history( - outgoing_message, + response, update_context=True, - update_full_history=True, - update_db=(self.message_db_conn is None) - ) - self.update_message_history(response, update_context=True, update_full_history=True) - - # Handle tool calls - tool_responses, tool_response_types = self.agent.handle_tool_call(response, return_tool_return_types=True) - for tool_response, tool_response_type in zip(tool_responses, tool_response_types): - print_message(tool_response) - self.update_message_history(tool_response, update_context=True, update_full_history=True) - - if len(tool_responses) >= 1: - for tool_response, tool_response_type in zip(tool_responses, tool_response_types): - # If the tool returns an image path, load the image and send it to - # the assistant in a follow-up message as user. - if tool_response_type == ToolReturnType.IMAGE_PATH: - image_path = tool_response["content"] - image_message = generate_openai_message( - content="Here is the image the tool returned.", - image_path=image_path, - role="user", - ) - self.update_message_history( - image_message, update_context=store_all_images_in_context, update_full_history=True - ) - # Send tool responses stored in the context - response = self.agent.receive( - message=None, - context=self.context, - return_outgoing_message=False + update_full_history=True ) - self.update_message_history(response, update_context=True, update_full_history=True) + continue def enter_monitoring_mode( self, From db584dd23ad616fedf5c48115035366bfc3d3866 Mon Sep 17 00:00:00 2001 From: mdw771 Date: Wed, 26 Nov 2025 16:36:56 -0600 Subject: [PATCH 18/21] CHORE: tweak prompt --- src/eaa/task_managers/imaging/feature_tracking.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/eaa/task_managers/imaging/feature_tracking.py b/src/eaa/task_managers/imaging/feature_tracking.py index 13d4576..34ef9fa 100644 --- a/src/eaa/task_managers/imaging/feature_tracking.py +++ b/src/eaa/task_managers/imaging/feature_tracking.py @@ -121,7 +121,9 @@ def run_fov_search( Parameters ---------- feature_description : str - A text description of the feature to search for. + A text description of the feature to search for. The message + can contain the tag to include a + reference image of the feature. y_range : tuple[float, float] The range of y coordinates to search for the feature. x_range : tuple[float, float] @@ -157,10 +159,11 @@ def run_fov_search( f"but go back to this size when you find the feature and acquire a " f"final image of it.\n" f"- Start from position (y={y_range[0]}, x={x_range[0]}), and gradually " - f"move the FOV to find the feature. Positions should not go beyond " - f"y={y_range[1]} and x={x_range[1]}. When moving the FOV, you can start " - f"with the step size of {step_size[0]} in the y direction and {step_size[1]} " - f"in the x direction. You can change the step sizes during the process.\n" + f"move the FOV to find the feature. Positions should stay in the range of " + f"y={y_range[0]} to {y_range[1]} and x={x_range[0]} to {x_range[1]}. \n" + f"- Use a regular grid search pattern at the beginning. Use a step size of {step_size[0]} " + f"in the y direction and {step_size[1]} in the x direction. When you see the\n" + f"feature, you can move the FOV more arbitrarily to make it better centered.\n" f"- When you find the feature, adjust the positions of the FOV to make the " f"feature centered in the FOV. If the feature is off to the left, move " f"the FOV to the left; if the feature is off to the top, move the FOV " @@ -170,6 +173,7 @@ def run_fov_search( f"stop the process.\n" f"- When you find the feature of interest, report the coordinates of the " f"FOV.\n" + f"- Explain every tool call you make." f"- When calling tools, make only one call at a time. Do not make " f"another call before getting the response of a previous one. \n" f"- When you finish the search or need user response, say 'TERMINATE'.\n" From 471a1dd1c2009726a223a290f7f75f32d624c6e8 Mon Sep 17 00:00:00 2001 From: mdw771 Date: Wed, 26 Nov 2025 16:38:30 -0600 Subject: [PATCH 19/21] CHORE: make reference image optional for focusing --- src/eaa/task_managers/tuning/focusing.py | 27 ++++++++++++------------ 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/eaa/task_managers/tuning/focusing.py b/src/eaa/task_managers/tuning/focusing.py index 6cb9d77..20b070a 100644 --- a/src/eaa/task_managers/tuning/focusing.py +++ b/src/eaa/task_managers/tuning/focusing.py @@ -375,13 +375,6 @@ def run( additional_prompt : Optional[str] If provided, this prompt will be added to the initial prompt. """ - if reference_image_path is None: - user_image_input = self.get_user_input( - prompt="Please provide the reference image as: .", - display_prompt_in_webui=True - ) - reference_image_path = get_image_path_from_text(user_image_input) - if reference_image_path is None and reference_feature_description is None: raise ValueError( "Either `reference_image_path` or `reference_feature_description` must be provided." @@ -392,12 +385,23 @@ def run( "`image_registration_tool` should be provided in the class constructor " "if `use_registration_in_workflow` is True." ) + + if reference_image_path is None: + reference_image_prompts = "" + else: + reference_image_prompts = ( + f"\n" + f"You will see a reference 2D scan image in this message. " + f"This image is acquired in the region of interest that " + f"contains the thin feature to be line-scanned. The line scan path " + f"across that feature is indicated by a marker." + ) if initial_prompt is None: feat_text_description = "" if reference_feature_description is not None: feat_text_description = ( - f"Also, here is the description of the feature: **{reference_feature_description}**. " + f"Here is the description of the feature: **{reference_feature_description}**. " ) param_step_size_prompt = "" if suggested_parameter_step_size is not None: @@ -441,11 +445,8 @@ def run( f"But each time you adjust the focus, the image may drift due to " f"the change of the optics. You will need to perform a 2D scan " f"prior to the line scan to locate the feature that is line-scanned.\n" - f"\n" - f"You will see a reference 2D scan image in this message. " - f"This image is acquired in the region of interest that " - f"contains the thin feature to be line-scanned. The line scan path " - f"across that feature is indicated by a marker. {feat_text_description}\n\n" + f"{reference_image_prompts}\n" + f"{feat_text_description}\n\n" f"Follow the procedure below to focus the microscope:\n\n" f"1. First, perform a 2D scan of the region of interest using the " f"\"acquire_image\" tool and the following arguments: " From a981880ab702ba234f881dc2a0a18313134b1035 Mon Sep 17 00:00:00 2001 From: Ming Du Date: Mon, 8 Dec 2025 19:14:41 -0600 Subject: [PATCH 20/21] CHORE: in focusing task, add prompt when registration in workflow is False but registration tool is given --- src/eaa/task_managers/tuning/focusing.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/eaa/task_managers/tuning/focusing.py b/src/eaa/task_managers/tuning/focusing.py index 20b070a..79c6096 100644 --- a/src/eaa/task_managers/tuning/focusing.py +++ b/src/eaa/task_managers/tuning/focusing.py @@ -432,7 +432,12 @@ def run( "Use the offset given by image registration to adjust the line scan positions." ) else: - registration_prompt = "" + registration_prompt = ( + "Use your registration tool to find the offset between the new image " + "and the previous one. Use this offset to adjust the line scan positions " + "by **adding** it to both the x and y coordinates of the start and end " + "points of the previous line scan. " + ) line_scan_positioning_prompt = ( "Read the coordinates of the line scan path from the axis ticks." ) From 426e90732f611eebd1565fef3e46890a743896de Mon Sep 17 00:00:00 2001 From: Ming Du Date: Mon, 8 Dec 2025 19:17:12 -0600 Subject: [PATCH 21/21] FIX: fix linting --- src/eaa/task_managers/tuning/focusing.py | 1 - src/eaa/tools/imaging/aps_mic/scan_control.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/eaa/task_managers/tuning/focusing.py b/src/eaa/task_managers/tuning/focusing.py index 79c6096..67d4263 100644 --- a/src/eaa/task_managers/tuning/focusing.py +++ b/src/eaa/task_managers/tuning/focusing.py @@ -15,7 +15,6 @@ from eaa.message_proc import print_message from eaa.api.llm_config import LLMConfig from eaa.api.memory import MemoryManagerConfig -from eaa.util import get_image_path_from_text import eaa.image_proc as ip from eaa.exceptions import MaxRoundsReached diff --git a/src/eaa/tools/imaging/aps_mic/scan_control.py b/src/eaa/tools/imaging/aps_mic/scan_control.py index a67bbf5..458a3ff 100644 --- a/src/eaa/tools/imaging/aps_mic/scan_control.py +++ b/src/eaa/tools/imaging/aps_mic/scan_control.py @@ -22,7 +22,7 @@ def __init__(self, require_approval: bool = False, *args, **kwargs): super().__init__(require_approval=require_approval, *args, **kwargs) try: - from s2idd_uprobe.startup import RE, oregistry, fly2d_scanrecord, step1d_scanrecord, bps + from s2idd_uprobe.startup import RE, oregistry, fly2d_scanrecord, step1d_scanrecord except ImportError: raise ImportError( "Bluesky control initialization failed. Please check that the bluesky-mic package is installed "