From 37892ed3951c278ed672850f405a868f04328831 Mon Sep 17 00:00:00 2001 From: Tom Willemsen Date: Wed, 1 Oct 2025 19:02:05 +0100 Subject: [PATCH 1/8] Set number of sampled points to 5000 in ISISCallbacks --- src/ibex_bluesky_core/callbacks/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ibex_bluesky_core/callbacks/__init__.py b/src/ibex_bluesky_core/callbacks/__init__.py index 9ddbd585..8d51ee80 100644 --- a/src/ibex_bluesky_core/callbacks/__init__.py +++ b/src/ibex_bluesky_core/callbacks/__init__.py @@ -229,7 +229,7 @@ def start(self, doc: RunStart) -> None: # where a fit result can be returned before # the QtAwareCallback has had a chance to process it. self._subs.append(self._live_fit) - self._subs.append(LiveFitPlot(livefit=self._live_fit, ax=ax)) + self._subs.append(LiveFitPlot(livefit=self._live_fit, ax=ax, num_points=5000)) else: self._subs.append(self._live_fit) From c773c9e0fe68ec244e5d9bf906ed9096d5b3bec5 Mon Sep 17 00:00:00 2001 From: Tom Willemsen Date: Wed, 1 Oct 2025 19:14:26 +0100 Subject: [PATCH 2/8] det_map: flood correction --- .../callbacks/reflectometry/_det_map.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/ibex_bluesky_core/callbacks/reflectometry/_det_map.py b/src/ibex_bluesky_core/callbacks/reflectometry/_det_map.py index abf43662..5e35fff9 100644 --- a/src/ibex_bluesky_core/callbacks/reflectometry/_det_map.py +++ b/src/ibex_bluesky_core/callbacks/reflectometry/_det_map.py @@ -26,6 +26,7 @@ import numpy as np import numpy.typing as npt import scipp as sc +import scipp.typing as sct from bluesky.callbacks.stream import LiveDispatcher from event_model import Event, EventDescriptor, RunStop @@ -46,12 +47,13 @@ class DetMapHeightScanLiveDispatcher(LiveDispatcher): monitor spectrum used for normalization). """ - def __init__(self, *, mon_name: str, det_name: str, out_name: str) -> None: + def __init__(self, *, mon_name: str, det_name: str, out_name: str, flood: sct.VariableLike | None = None) -> None: """Init.""" super().__init__() self._mon_name = mon_name self._det_name = det_name self._out_name = out_name + self._flood = flood if flood is not None else sc.scalar(value=1, dtype="float64") def event(self, doc: Event, **kwargs: dict[str, Any]) -> Event: """Process an event.""" @@ -62,6 +64,8 @@ def event(self, doc: Event, **kwargs: dict[str, Any]) -> Event: det = sc.Variable(dims=["spectrum"], values=det_data, variances=det_data, dtype="float64") mon = sc.Variable(dims=["spectrum"], values=mon_data, variances=mon_data, dtype="float64") + det /= self._flood + det_sum = det.sum() mon_sum = mon.sum() @@ -90,19 +94,21 @@ class DetMapAngleScanLiveDispatcher(LiveDispatcher): """ def __init__( - self, x_data: npt.NDArray[np.float64], x_name: str, y_in_name: str, y_out_name: str + self, x_data: npt.NDArray[np.float64], x_name: str, y_in_name: str, y_out_name: str, flood: sct.VariableLike | None = None ) -> None: """Init.""" super().__init__() self.x_data = x_data self.x_name = x_name - self.y_data = np.zeros_like(x_data) + self.y_data = sc.array(dims=["spectrum"], values=np.zeros_like(x_data), variances=np.zeros_like(x_data), dtype="float64") self.y_in_name: str = y_in_name self.y_out_name: str = y_out_name self._descriptor_uid: str | None = None + self._flood = flood if flood is not None else sc.scalar(value=1, dtype="float64") + def descriptor(self, doc: EventDescriptor) -> None: """Process a descriptor.""" self._descriptor_uid = doc["uid"] @@ -118,7 +124,8 @@ def event(self, doc: Event, **kwargs: dict[str, Any]) -> Event: f"Shape of data ({data.shape} does not match x_data.shape ({self.x_data.shape})" ) - self.y_data += data + scaled_data = sc.array(dims=["spectrum"], values=data, variances=data, dtype="float64") / self._flood + self.y_data += scaled_data return doc def stop(self, doc: RunStop, _md: dict[str, Any] | None = None) -> None: @@ -133,8 +140,8 @@ def stop(self, doc: RunStop, _md: dict[str, Any] | None = None) -> None: event = { "data": { self.x_name: x, - self.y_out_name: y, - self.y_out_name + "_err": np.sqrt(y + 0.5), + self.y_out_name: y.value, + self.y_out_name + "_err": np.sqrt(y.variance + 0.5), }, "timestamps": { self.x_name: current_time, From 045287dfda32a2e6175e9130934c0f3840ec4590 Mon Sep 17 00:00:00 2001 From: Tom Willemsen Date: Wed, 1 Oct 2025 19:16:47 +0100 Subject: [PATCH 3/8] gaussian fit guess improvements --- src/ibex_bluesky_core/fitting.py | 35 ++++++++++++++++---------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/ibex_bluesky_core/fitting.py b/src/ibex_bluesky_core/fitting.py index 25fcdaf1..c035eae0 100644 --- a/src/ibex_bluesky_core/fitting.py +++ b/src/ibex_bluesky_core/fitting.py @@ -1,5 +1,5 @@ """Fitting methods used by the LiveFit callback.""" - +import math from abc import ABC, abstractmethod from collections.abc import Callable @@ -102,6 +102,19 @@ def fit(cls, *args: int) -> FitMethod: return FitMethod(model=cls.model(*args), guess=cls.guess(*args)) +def _guess_cen_and_width( + x: npt.NDArray[np.float64], y: npt.NDArray[np.float64] +) -> tuple[float, float]: + """Guess the center and width of a positive peak.""" + com, total_area = center_of_mass_of_area_under_curve(x, y) + y_range = np.max(y) - np.min(y) + if y_range == 0.0: + width = (np.max(x) - np.min(x)) / 2 + else: + width = total_area / y_range + return com, width + + class Gaussian(Fit): """Gaussian Fitting.""" @@ -130,8 +143,9 @@ def guess( def guess( x: npt.NDArray[np.float64], y: npt.NDArray[np.float64] ) -> dict[str, lmfit.Parameter]: - mean = np.sum(x * y) / np.sum(y) - sigma = np.sqrt(np.sum(y * (x - mean) ** 2) / np.sum(y)) + cen, width = _guess_cen_and_width(x, y) + sigma = width / math.sqrt(2 * math.pi) # From expected area under gaussian + background = np.min(y) if np.max(y) > abs(np.min(y)): @@ -142,7 +156,7 @@ def guess( init_guess = { "amp": lmfit.Parameter("amp", amp), "sigma": lmfit.Parameter("sigma", sigma, min=0), - "x0": lmfit.Parameter("x0", mean), + "x0": lmfit.Parameter("x0", cen), "background": lmfit.Parameter("background", background), } @@ -547,19 +561,6 @@ def guess( return guess -def _guess_cen_and_width( - x: npt.NDArray[np.float64], y: npt.NDArray[np.float64] -) -> tuple[float, float]: - """Guess the center and width of a positive peak.""" - com, total_area = center_of_mass_of_area_under_curve(x, y) - y_range = np.max(y) - np.min(y) - if y_range == 0.0: - width = (np.max(x) - np.min(x)) / 2 - else: - width = total_area / y_range - return com, width - - class TopHat(Fit): """Top Hat Fitting.""" From 51706114a32ccbdfbc047a92fb6c53715ad21139 Mon Sep 17 00:00:00 2001 From: Tom Willemsen Date: Wed, 1 Oct 2025 19:30:58 +0100 Subject: [PATCH 4/8] flood & fits --- .../plans/reflectometry/_det_map_align.py | 61 ++++++++++++++++--- 1 file changed, 53 insertions(+), 8 deletions(-) diff --git a/src/ibex_bluesky_core/plans/reflectometry/_det_map_align.py b/src/ibex_bluesky_core/plans/reflectometry/_det_map_align.py index 2291a99e..246af932 100644 --- a/src/ibex_bluesky_core/plans/reflectometry/_det_map_align.py +++ b/src/ibex_bluesky_core/plans/reflectometry/_det_map_align.py @@ -9,6 +9,7 @@ import matplotlib.pyplot as plt import numpy as np import numpy.typing as npt +import scipp.typing as sct from bluesky.preprocessors import subs_decorator from bluesky.protocols import NamedMovable from bluesky.utils import Msg @@ -16,7 +17,7 @@ from matplotlib.axes import Axes from ophyd_async.plan_stubs import ensure_connected -from ibex_bluesky_core.callbacks import ISISCallbacks, LiveFit, LivePColorMesh +from ibex_bluesky_core.callbacks import ISISCallbacks, LiveFit, LivePColorMesh, PlotPNGSaver from ibex_bluesky_core.callbacks.reflectometry import ( DetMapAngleScanLiveDispatcher, DetMapHeightScanLiveDispatcher, @@ -28,7 +29,7 @@ Waiter, check_dae_strategies, ) -from ibex_bluesky_core.fitting import Gaussian +from ibex_bluesky_core.fitting import Gaussian, FitMethod from ibex_bluesky_core.plan_stubs import call_qt_aware __all__ = ["DetMapAlignResult", "angle_scan_plan", "height_and_angle_scan_plan"] @@ -38,13 +39,14 @@ def _height_scan_callback_and_fit( - reducer: PeriodSpecIntegralsReducer, height: NamedMovable[float], ax: Axes + reducer: PeriodSpecIntegralsReducer, height: NamedMovable[float], ax: Axes, flood: sct.VariableLike | None ) -> tuple[DetMapHeightScanLiveDispatcher, LiveFit]: intensity = "intensity" height_scan_ld = DetMapHeightScanLiveDispatcher( mon_name=reducer.mon_integrals.name, det_name=reducer.det_integrals.name, out_name=intensity, + flood=flood, ) height_scan_callbacks = ISISCallbacks( @@ -56,15 +58,26 @@ def _height_scan_callback_and_fit( ax=ax, live_fit_logger_postfix="_height", human_readable_file_postfix="_height", + save_plot_to_png=False, ) for cb in height_scan_callbacks.subs: height_scan_ld.subscribe(cb) + def set_title_to_height_fit_result(*args, **kwargs): + fit_result = height_scan_callbacks.live_fit.result + if fit_result is not None: + ax.set_title( + f"Best x0: {fit_result.params['x0'].value:.4f} +/- {fit_result.params['x0'].stderr:.4f}" + ) + plt.draw() + + height_scan_ld.subscribe(set_title_to_height_fit_result, "stop") + return height_scan_ld, height_scan_callbacks.live_fit def _angle_scan_callback_and_fit( - reducer: PeriodSpecIntegralsReducer, angle_map: npt.NDArray[np.float64], ax: Axes + reducer: PeriodSpecIntegralsReducer, angle_map: npt.NDArray[np.float64], ax: Axes, flood: sct.VariableLike | None ) -> tuple[DetMapAngleScanLiveDispatcher, LiveFit]: angle_name = "angle" counts_name = "counts" @@ -74,13 +87,29 @@ def _angle_scan_callback_and_fit( x_name=angle_name, y_in_name=reducer.det_integrals.name, y_out_name=counts_name, + flood=flood, + ) + + # The angle fit is prone to having skewed data which drags centre-of-mass + # guess away from real peak. For this data, it's actually better to guess the + # centre as the x-value corresponding to the max y-value. The guesses for background, + # sigma, and amplitude are left as-is. + def gaussian_max_y_guess(x, y): + guess = Gaussian().guess()(x, y) + max_y_idx = np.argmax(y) + guess["x0"] = x[max_y_idx] + return guess + + fit_method = FitMethod( + model = Gaussian.model(), + guess = gaussian_max_y_guess, ) angle_scan_callbacks = ISISCallbacks( x=angle_name, y=counts_name, yerr=f"{counts_name}_err", - fit=Gaussian().fit(), + fit=fit_method, add_peak_stats=False, add_table_cb=False, ax=ax, @@ -88,10 +117,24 @@ def _angle_scan_callback_and_fit( human_readable_file_postfix="_angle", live_fit_update_every=len(angle_map) - 1, live_plot_update_on_every_event=False, + save_plot_to_png=False, ) for cb in angle_scan_callbacks.subs: angle_scan_ld.subscribe(cb) + def set_title_to_angle_fit_result(*args, **kwargs): + fit_result = angle_scan_callbacks.live_fit.result + if fit_result is not None: + ax.set_title( + f"Best x0: {fit_result.params['x0'].value:.4f} +/- {fit_result.params['x0'].stderr:.4f}" + ) + plt.draw() + + angle_scan_ld.subscribe(set_title_to_angle_fit_result, "stop") + + # Make sure the Plot PNG saving happens *after* setting plot title to fit result... + angle_scan_ld.subscribe(PlotPNGSaver(x=angle_name, y=counts_name, ax=ax, postfix="", output_dir=None)) + return angle_scan_ld, angle_scan_callbacks.live_fit @@ -110,6 +153,7 @@ def angle_scan_plan( dae: SimpleDae[PeriodPerPointController, Waiter, PeriodSpecIntegralsReducer], *, angle_map: npt.NDArray[np.float64], + flood: sct.VariableLike | None = None, ) -> Generator[Msg, None, ModelResult | None]: """Reflectometry detector-mapping angle alignment plan. @@ -138,7 +182,7 @@ def angle_scan_plan( yield from call_qt_aware(plt.show) _, ax = yield from call_qt_aware(plt.subplots) - angle_cb, angle_fit = _angle_scan_callback_and_fit(reducer, angle_map, ax) + angle_cb, angle_fit = _angle_scan_callback_and_fit(reducer, angle_map, ax, flood=flood) @subs_decorator( [ @@ -180,6 +224,7 @@ def height_and_angle_scan_plan( # noqa PLR0913 num: int, angle_map: npt.NDArray[np.float64], rel: bool = False, + flood: sct.VariableLike | None = None, ) -> Generator[Msg, None, DetMapAlignResult]: """Reflectometry detector-mapping simultaneous height & angle alignment plan. @@ -224,8 +269,8 @@ def height_and_angle_scan_plan( # noqa PLR0913 cmap="hot", shading="auto", ) - height_cb, height_fit = _height_scan_callback_and_fit(reducer, height, height_ax) - angle_cb, angle_fit = _angle_scan_callback_and_fit(reducer, angle_map, angle_ax) + height_cb, height_fit = _height_scan_callback_and_fit(reducer, height, height_ax, flood=flood) + angle_cb, angle_fit = _angle_scan_callback_and_fit(reducer, angle_map, angle_ax, flood=flood) @subs_decorator( [ From ff1f35100e9bcc8366ed223c7042b2a58199c450 Mon Sep 17 00:00:00 2001 From: Tom Willemsen Date: Wed, 1 Oct 2025 19:42:22 +0100 Subject: [PATCH 5/8] ruff --- .../callbacks/reflectometry/_det_map.py | 22 ++++++-- src/ibex_bluesky_core/fitting.py | 1 + .../plans/reflectometry/_det_map_align.py | 50 +++++++++++++------ 3 files changed, 55 insertions(+), 18 deletions(-) diff --git a/src/ibex_bluesky_core/callbacks/reflectometry/_det_map.py b/src/ibex_bluesky_core/callbacks/reflectometry/_det_map.py index 5e35fff9..b8df9fc3 100644 --- a/src/ibex_bluesky_core/callbacks/reflectometry/_det_map.py +++ b/src/ibex_bluesky_core/callbacks/reflectometry/_det_map.py @@ -47,7 +47,9 @@ class DetMapHeightScanLiveDispatcher(LiveDispatcher): monitor spectrum used for normalization). """ - def __init__(self, *, mon_name: str, det_name: str, out_name: str, flood: sct.VariableLike | None = None) -> None: + def __init__( + self, *, mon_name: str, det_name: str, out_name: str, flood: sct.VariableLike | None = None + ) -> None: """Init.""" super().__init__() self._mon_name = mon_name @@ -94,14 +96,24 @@ class DetMapAngleScanLiveDispatcher(LiveDispatcher): """ def __init__( - self, x_data: npt.NDArray[np.float64], x_name: str, y_in_name: str, y_out_name: str, flood: sct.VariableLike | None = None + self, + x_data: npt.NDArray[np.float64], + x_name: str, + y_in_name: str, + y_out_name: str, + flood: sct.VariableLike | None = None, ) -> None: """Init.""" super().__init__() self.x_data = x_data self.x_name = x_name - self.y_data = sc.array(dims=["spectrum"], values=np.zeros_like(x_data), variances=np.zeros_like(x_data), dtype="float64") + self.y_data = sc.array( + dims=["spectrum"], + values=np.zeros_like(x_data), + variances=np.zeros_like(x_data), + dtype="float64", + ) self.y_in_name: str = y_in_name self.y_out_name: str = y_out_name @@ -124,7 +136,9 @@ def event(self, doc: Event, **kwargs: dict[str, Any]) -> Event: f"Shape of data ({data.shape} does not match x_data.shape ({self.x_data.shape})" ) - scaled_data = sc.array(dims=["spectrum"], values=data, variances=data, dtype="float64") / self._flood + scaled_data = ( + sc.array(dims=["spectrum"], values=data, variances=data, dtype="float64") / self._flood + ) self.y_data += scaled_data return doc diff --git a/src/ibex_bluesky_core/fitting.py b/src/ibex_bluesky_core/fitting.py index c035eae0..d1bd8275 100644 --- a/src/ibex_bluesky_core/fitting.py +++ b/src/ibex_bluesky_core/fitting.py @@ -1,4 +1,5 @@ """Fitting methods used by the LiveFit callback.""" + import math from abc import ABC, abstractmethod from collections.abc import Callable diff --git a/src/ibex_bluesky_core/plans/reflectometry/_det_map_align.py b/src/ibex_bluesky_core/plans/reflectometry/_det_map_align.py index 246af932..87f02ccd 100644 --- a/src/ibex_bluesky_core/plans/reflectometry/_det_map_align.py +++ b/src/ibex_bluesky_core/plans/reflectometry/_det_map_align.py @@ -2,10 +2,11 @@ import logging from collections.abc import Generator -from typing import TypedDict +from typing import Any, TypedDict import bluesky.plan_stubs as bps import bluesky.plans as bp +import lmfit import matplotlib.pyplot as plt import numpy as np import numpy.typing as npt @@ -13,6 +14,7 @@ from bluesky.preprocessors import subs_decorator from bluesky.protocols import NamedMovable from bluesky.utils import Msg +from lmfit import Parameter from lmfit.model import ModelResult from matplotlib.axes import Axes from ophyd_async.plan_stubs import ensure_connected @@ -29,7 +31,7 @@ Waiter, check_dae_strategies, ) -from ibex_bluesky_core.fitting import Gaussian, FitMethod +from ibex_bluesky_core.fitting import FitMethod, Gaussian from ibex_bluesky_core.plan_stubs import call_qt_aware __all__ = ["DetMapAlignResult", "angle_scan_plan", "height_and_angle_scan_plan"] @@ -39,7 +41,10 @@ def _height_scan_callback_and_fit( - reducer: PeriodSpecIntegralsReducer, height: NamedMovable[float], ax: Axes, flood: sct.VariableLike | None + reducer: PeriodSpecIntegralsReducer, + height: NamedMovable[float], + ax: Axes, + flood: sct.VariableLike | None, ) -> tuple[DetMapHeightScanLiveDispatcher, LiveFit]: intensity = "intensity" height_scan_ld = DetMapHeightScanLiveDispatcher( @@ -63,11 +68,12 @@ def _height_scan_callback_and_fit( for cb in height_scan_callbacks.subs: height_scan_ld.subscribe(cb) - def set_title_to_height_fit_result(*args, **kwargs): + def set_title_to_height_fit_result(name: str, doc: dict[str, Any]) -> None: fit_result = height_scan_callbacks.live_fit.result if fit_result is not None: ax.set_title( - f"Best x0: {fit_result.params['x0'].value:.4f} +/- {fit_result.params['x0'].stderr:.4f}" + f"Best x0: {fit_result.params['x0'].value:.4f} " + f"+/- {fit_result.params['x0'].stderr:.4f}" ) plt.draw() @@ -77,7 +83,10 @@ def set_title_to_height_fit_result(*args, **kwargs): def _angle_scan_callback_and_fit( - reducer: PeriodSpecIntegralsReducer, angle_map: npt.NDArray[np.float64], ax: Axes, flood: sct.VariableLike | None + reducer: PeriodSpecIntegralsReducer, + angle_map: npt.NDArray[np.float64], + ax: Axes, + flood: sct.VariableLike | None, ) -> tuple[DetMapAngleScanLiveDispatcher, LiveFit]: angle_name = "angle" counts_name = "counts" @@ -94,15 +103,17 @@ def _angle_scan_callback_and_fit( # guess away from real peak. For this data, it's actually better to guess the # centre as the x-value corresponding to the max y-value. The guesses for background, # sigma, and amplitude are left as-is. - def gaussian_max_y_guess(x, y): + def gaussian_max_y_guess( + x: npt.NDArray[np.float64], y: npt.NDArray[np.float64] + ) -> dict[str, Parameter]: guess = Gaussian().guess()(x, y) max_y_idx = np.argmax(y) - guess["x0"] = x[max_y_idx] + guess["x0"] = lmfit.Parameter("x0", x[max_y_idx]) return guess - + fit_method = FitMethod( - model = Gaussian.model(), - guess = gaussian_max_y_guess, + model=Gaussian.model(), + guess=gaussian_max_y_guess, ) angle_scan_callbacks = ISISCallbacks( @@ -122,18 +133,21 @@ def gaussian_max_y_guess(x, y): for cb in angle_scan_callbacks.subs: angle_scan_ld.subscribe(cb) - def set_title_to_angle_fit_result(*args, **kwargs): + def set_title_to_angle_fit_result(name: str, doc: dict[str, Any]) -> None: fit_result = angle_scan_callbacks.live_fit.result if fit_result is not None: ax.set_title( - f"Best x0: {fit_result.params['x0'].value:.4f} +/- {fit_result.params['x0'].stderr:.4f}" + f"Best x0: {fit_result.params['x0'].value:.4f} " + f"+/- {fit_result.params['x0'].stderr:.4f}" ) plt.draw() angle_scan_ld.subscribe(set_title_to_angle_fit_result, "stop") # Make sure the Plot PNG saving happens *after* setting plot title to fit result... - angle_scan_ld.subscribe(PlotPNGSaver(x=angle_name, y=counts_name, ax=ax, postfix="", output_dir=None)) + angle_scan_ld.subscribe( + PlotPNGSaver(x=angle_name, y=counts_name, ax=ax, postfix="", output_dir=None) + ) return angle_scan_ld, angle_scan_callbacks.live_fit @@ -165,6 +179,10 @@ def angle_scan_plan( dae: The DAE to acquire from angle_map: a numpy array, with the same shape as detectors, describing the detector angle of each detector pixel + flood: Optional scipp data array describing a flood-correction. + This array should be aligned along a "spectrum" dimension; counts are + divided by this array before being used in fits. This is used to + normalise the intensities detected by each detector pixel. """ logger.info("Starting angle scan") @@ -240,6 +258,10 @@ def height_and_angle_scan_plan( # noqa PLR0913 angle_map: a numpy array, with the same shape as detectors, describing the detector angle of each detector pixel rel: whether this scan should be absolute (default) or relative + flood: Optional scipp data array describing a flood-correction. + This array should be aligned along a "spectrum" dimension; counts are + divided by this array before being used in fits. This is used to + normalise the intensities detected by each detector pixel. Returns: A dictionary containing the fit results from gaussian height and angle fits. From f63067cf3e3ec0ebecb216af09a7b2de436433ed Mon Sep 17 00:00:00 2001 From: Tom Willemsen Date: Wed, 1 Oct 2025 19:49:46 +0100 Subject: [PATCH 6/8] pyright --- .../callbacks/reflectometry/_det_map.py | 7 +++---- .../plans/reflectometry/_det_map_align.py | 10 +++++----- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/ibex_bluesky_core/callbacks/reflectometry/_det_map.py b/src/ibex_bluesky_core/callbacks/reflectometry/_det_map.py index b8df9fc3..8b2f9241 100644 --- a/src/ibex_bluesky_core/callbacks/reflectometry/_det_map.py +++ b/src/ibex_bluesky_core/callbacks/reflectometry/_det_map.py @@ -26,7 +26,6 @@ import numpy as np import numpy.typing as npt import scipp as sc -import scipp.typing as sct from bluesky.callbacks.stream import LiveDispatcher from event_model import Event, EventDescriptor, RunStop @@ -48,7 +47,7 @@ class DetMapHeightScanLiveDispatcher(LiveDispatcher): """ def __init__( - self, *, mon_name: str, det_name: str, out_name: str, flood: sct.VariableLike | None = None + self, *, mon_name: str, det_name: str, out_name: str, flood: sc.Variable | None = None ) -> None: """Init.""" super().__init__() @@ -101,7 +100,7 @@ def __init__( x_name: str, y_in_name: str, y_out_name: str, - flood: sct.VariableLike | None = None, + flood: sc.Variable | None = None, ) -> None: """Init.""" super().__init__() @@ -149,7 +148,7 @@ def stop(self, doc: RunStop, _md: dict[str, Any] | None = None) -> None: return super().stop(doc, _md) current_time = time.time() - for x, y in zip(self.x_data, self.y_data, strict=True): + for x, y in zip(self.x_data, self.y_data, strict=True): # type: ignore logger.debug("DetMapAngleScanLiveDispatcher emitting event with x=%f, y=%f", x, y) event = { "data": { diff --git a/src/ibex_bluesky_core/plans/reflectometry/_det_map_align.py b/src/ibex_bluesky_core/plans/reflectometry/_det_map_align.py index 87f02ccd..9440911f 100644 --- a/src/ibex_bluesky_core/plans/reflectometry/_det_map_align.py +++ b/src/ibex_bluesky_core/plans/reflectometry/_det_map_align.py @@ -10,7 +10,7 @@ import matplotlib.pyplot as plt import numpy as np import numpy.typing as npt -import scipp.typing as sct +import scipp as sc from bluesky.preprocessors import subs_decorator from bluesky.protocols import NamedMovable from bluesky.utils import Msg @@ -44,7 +44,7 @@ def _height_scan_callback_and_fit( reducer: PeriodSpecIntegralsReducer, height: NamedMovable[float], ax: Axes, - flood: sct.VariableLike | None, + flood: sc.Variable | None, ) -> tuple[DetMapHeightScanLiveDispatcher, LiveFit]: intensity = "intensity" height_scan_ld = DetMapHeightScanLiveDispatcher( @@ -86,7 +86,7 @@ def _angle_scan_callback_and_fit( reducer: PeriodSpecIntegralsReducer, angle_map: npt.NDArray[np.float64], ax: Axes, - flood: sct.VariableLike | None, + flood: sc.Variable | None, ) -> tuple[DetMapAngleScanLiveDispatcher, LiveFit]: angle_name = "angle" counts_name = "counts" @@ -167,7 +167,7 @@ def angle_scan_plan( dae: SimpleDae[PeriodPerPointController, Waiter, PeriodSpecIntegralsReducer], *, angle_map: npt.NDArray[np.float64], - flood: sct.VariableLike | None = None, + flood: sc.Variable | None = None, ) -> Generator[Msg, None, ModelResult | None]: """Reflectometry detector-mapping angle alignment plan. @@ -242,7 +242,7 @@ def height_and_angle_scan_plan( # noqa PLR0913 num: int, angle_map: npt.NDArray[np.float64], rel: bool = False, - flood: sct.VariableLike | None = None, + flood: sc.Variable | None = None, ) -> Generator[Msg, None, DetMapAlignResult]: """Reflectometry detector-mapping simultaneous height & angle alignment plan. From 27a662e4b53d2602bd70a7e64788e6cdbf79b737 Mon Sep 17 00:00:00 2001 From: Tom Willemsen Date: Thu, 2 Oct 2025 10:10:43 +0100 Subject: [PATCH 7/8] Fix tests --- src/ibex_bluesky_core/plans/reflectometry/_det_map_align.py | 4 ++++ tests/callbacks/fitting/test_fitting_methods.py | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/ibex_bluesky_core/plans/reflectometry/_det_map_align.py b/src/ibex_bluesky_core/plans/reflectometry/_det_map_align.py index 9440911f..9e8ecc3a 100644 --- a/src/ibex_bluesky_core/plans/reflectometry/_det_map_align.py +++ b/src/ibex_bluesky_core/plans/reflectometry/_det_map_align.py @@ -75,6 +75,8 @@ def set_title_to_height_fit_result(name: str, doc: dict[str, Any]) -> None: f"Best x0: {fit_result.params['x0'].value:.4f} " f"+/- {fit_result.params['x0'].stderr:.4f}" ) + else: + ax.set_title("Fit failed") # pragma: no cover plt.draw() height_scan_ld.subscribe(set_title_to_height_fit_result, "stop") @@ -140,6 +142,8 @@ def set_title_to_angle_fit_result(name: str, doc: dict[str, Any]) -> None: f"Best x0: {fit_result.params['x0'].value:.4f} " f"+/- {fit_result.params['x0'].stderr:.4f}" ) + else: + ax.set_title("Fit failed") # pragma: no cover plt.draw() angle_scan_ld.subscribe(set_title_to_angle_fit_result, "stop") diff --git a/tests/callbacks/fitting/test_fitting_methods.py b/tests/callbacks/fitting/test_fitting_methods.py index 7ad069d5..de14a662 100644 --- a/tests/callbacks/fitting/test_fitting_methods.py +++ b/tests/callbacks/fitting/test_fitting_methods.py @@ -133,10 +133,10 @@ def test_neg_amp_x0(self): assert outp["amp"] < 0 def test_sigma(self): - x = np.array([-1.0, 0.0, 1.0], dtype=np.float64) - y = np.array([1.0, 2.0, 1.0], dtype=np.float64) + x = np.array([-2.0, -1.0, 0.0, 1.0, 2.0], dtype=np.float64) + y = np.array([0.0, 0.0, 2.0, 0.0, 0.0], dtype=np.float64) # y1 is "wider" so must have higher sigma - y1 = np.array([1.5, 1.75, 1.5], dtype=np.float64) + y1 = np.array([0.0, 2.0, 2.0, 2.0, 0.0], dtype=np.float64) outp = Gaussian.guess()(x, y) outp1 = Gaussian.guess()(x, y1) From 0dc33cc1a31b7b7b7f51c7791d9d8f3552cdce9d Mon Sep 17 00:00:00 2001 From: Tom Willemsen Date: Thu, 2 Oct 2025 13:36:55 +0100 Subject: [PATCH 8/8] Doc --- doc/plans/reflectometry/detector_mapping_alignment.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/doc/plans/reflectometry/detector_mapping_alignment.md b/doc/plans/reflectometry/detector_mapping_alignment.md index a7f00245..69cbfaa8 100644 --- a/doc/plans/reflectometry/detector_mapping_alignment.md +++ b/doc/plans/reflectometry/detector_mapping_alignment.md @@ -9,6 +9,10 @@ The plans in this module expect: {py:obj}`ibex_bluesky_core.devices.simpledae.PeriodSpecIntegralsReducer`. - An angle map, as a `numpy` array of type `float64`, which has the same dimensionality as the set of selected detectors. This maps each configured detector pixel to its angular position. +- An optional flood map, as a {external+scipp:py:obj}`scipp.Variable`. This should have a dimension label of "spectrum" +and have the same dimensionality as the set of selected detectors. This array may have variances. This is used to +normalise pixel efficiencies: raw counts are divided by the flood to get scaled counts. If no flood map is provided, no +normalisation will be performed. ## Angle scan