Skip to content

Commit fec32f3

Browse files
authored
Merge pull request #218 from ISISComputingGroup/centre_of_mass_callback
Centre of mass callback
2 parents 411de60 + 13f773a commit fec32f3

File tree

8 files changed

+352
-42
lines changed

8 files changed

+352
-42
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Using our own Centre of Mass Callback
2+
3+
## Status
4+
5+
Current
6+
7+
## Context
8+
9+
A decision needs to be made about whether to make changes to upstream Bluesky so that their `CoM` callback works for us, or we make our own.
10+
11+
## Decision
12+
13+
We will be making our own `CoM` callback.
14+
15+
## Justification & Consequences
16+
17+
We attempted to make changes to upstream Bluesky which were rejected, as it adds limits to the functionality of the callback. We also found other limitations with using their callback, such as not being able to have disordered and non-continuous data sent to it without it skewing the calculated value- we need it to work with disordered and non-continuous data as we need to be able to run continuous scans.
18+
19+
This will mean that...
20+
- Our version of the callback will not be supported by Bluesky and may need changes as Bluesky updates.
21+
- We can have a version of the callback that is made bespokely for our use cases.

doc/fitting/fitting.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,40 @@ lf = LiveFit(fit_method, ...)
214214
```
215215
See the [standard fits](#models) list above for standard fits which require parameters. It gets more complicated if you want to define your own custom model or guess which you want to pass parameters to. You will have to define a function that takes these parameters and returns the model / guess function with the subsituted values.
216216

217+
# Centre of Mass
218+
219+
[`CentreOfMass`](ibex_bluesky_core.callbacks.CentreOfMass) is a callback that provides functionality for calculating our definition of Centre of Mass. We calculate centre of mass from the 2D region bounded by min(y), min(x), max(x), and straight-line segments joining (x, y) data points with their nearest neighbours along the x axis.
220+
221+
[`CentreOfMass`](ibex_bluesky_core.callbacks.CentreOfMass) has a property, `result` which stores the centre of mass value once the callback has finished.
222+
223+
In order to use the callback, import `CentreOfMass` from `ibex_bluesky_core.callbacks`.
224+
```py
225+
from ibex_bluesky_core.callbacks import CentreOfMass
226+
```
227+
228+
## Our CoM Algorithm
229+
230+
Given non-continuous arrays of collected data `x` and `y`, ({py:obj}`ibex_bluesky_core.callbacks.CentreOfMass`) returns the `x` value of the centre of mass.
231+
232+
Our use cases require that our algorithm abides to the following rules:
233+
- Any background on data does not skew the centre of mass
234+
- The order in which data is received does not skew the centre of mass
235+
- Should support non-constant point spacing without skewing the centre of mass
236+
237+
*Note that this is designed for only **positive** peaks.*
238+
239+
### Step-by-step
240+
241+
1) Sort `x` and `y` arrays in respect of `x` ascending. This is so that data can be received in any order.
242+
2) From each `y` element, subtract `min(y)`. This means that any constant background over data is ignored. (Does not work for negative peaks)
243+
3) Calculate weight/widths for each point; based on it's `x` distances from neighbouring points. This ensures non-constant point spacing is accounted for in our calculation.
244+
4) For each decomposed shape that makes up the total area under the curve, `CoM` is calculated as the following:
245+
```{math}
246+
com_x = \frac{\sum_{}^{}x * y * \text{weight}}{\sum_{}^{}y * \text{weight}}
247+
```
248+
249+
[`CentreOfMass`](ibex_bluesky_core.callbacks.CentreOfMass) can be used from our callbacks collection. See [ISISCallbacks](ibex_bluesky_core.callbacks.ISISCallbacks).
250+
217251
## Chained Fitting
218252

219253
[`ChainedLiveFit`](ibex_bluesky_core.callbacks.ChainedLiveFit) is a specialised callback that manages multiple LiveFit instances in a chain, where each fit's results inform the next fit's initial parameters. This is particularly useful when dealing with complex data sets where subsequent fits depend on the parameters obtained from previous fits.

src/ibex_bluesky_core/callbacks/__init__.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,18 @@
1616
from event_model import RunStart
1717
from matplotlib.axes import Axes
1818

19+
from ibex_bluesky_core.callbacks._centre_of_mass import (
20+
CentreOfMass,
21+
)
1922
from ibex_bluesky_core.callbacks._document_logger import DocLoggingCallback
2023
from ibex_bluesky_core.callbacks._file_logger import (
2124
HumanReadableFileCallback,
2225
)
23-
from ibex_bluesky_core.callbacks._fitting import ChainedLiveFit, LiveFit, LiveFitLogger
26+
from ibex_bluesky_core.callbacks._fitting import (
27+
ChainedLiveFit,
28+
LiveFit,
29+
LiveFitLogger,
30+
)
2431
from ibex_bluesky_core.callbacks._plotting import LivePColorMesh, LivePlot, PlotPNGSaver, show_plot
2532
from ibex_bluesky_core.callbacks._utils import get_default_output_path
2633
from ibex_bluesky_core.fitting import FitMethod
@@ -32,6 +39,7 @@
3239

3340

3441
__all__ = [
42+
"CentreOfMass",
3543
"ChainedLiveFit",
3644
"DocLoggingCallback",
3745
"HumanReadableFileCallback",
@@ -49,7 +57,7 @@
4957
class ISISCallbacks:
5058
"""ISIS standard callbacks for use within plans."""
5159

52-
def __init__( # noqa: PLR0912
60+
def __init__( # noqa: PLR0912, PLR0915
5361
self,
5462
*,
5563
x: str,
@@ -66,6 +74,7 @@ def __init__( # noqa: PLR0912
6674
fit: FitMethod | None = None,
6775
show_fit_on_plot: bool = True,
6876
add_peak_stats: bool = True,
77+
add_centre_of_mass: bool = True,
6978
add_live_fit_logger: bool = True,
7079
live_fit_logger_output_dir: str | PathLike[str] | None = None,
7180
live_fit_logger_postfix: str = "",
@@ -129,6 +138,7 @@ def _inner():
129138
fit: The fit method to use when fitting.
130139
show_fit_on_plot: whether to show fit on plot.
131140
add_peak_stats: whether to add a peak stats callback.
141+
add_centre_of_mass: whether to add a centre of mass callback.
132142
add_live_fit_logger: whether to add a live fit logger.
133143
live_fit_logger_output_dir: the output directory for live fit logger.
134144
live_fit_logger_postfix: the postfix to add to live fit logger.
@@ -142,6 +152,7 @@ def _inner():
142152
fig = None
143153
self._subs = []
144154
self._peak_stats = None
155+
self._com = None
145156
self._live_fit = None
146157
if measured_fields is None:
147158
measured_fields = []
@@ -177,6 +188,10 @@ def _inner():
177188
self._peak_stats = PeakStats(x=x, y=y)
178189
self._subs.append(self._peak_stats)
179190

191+
if add_centre_of_mass:
192+
self._com = CentreOfMass(x=x, y=y)
193+
self._subs.append(self._com)
194+
180195
if (add_plot_cb or show_fit_on_plot) and not ax:
181196
logger.debug("No axis provided, creating a new one")
182197
fig, ax = None, None
@@ -260,11 +275,18 @@ def live_fit(self) -> LiveFit:
260275

261276
@property
262277
def peak_stats(self) -> PeakStats:
263-
"""The peak stats object containing statistics ie. centre of mass."""
278+
"""The peak stats object containing statistics ie. bluesky's centre of mass."""
264279
if self._peak_stats is None:
265280
raise ValueError("peak stats was not added as a callback.")
266281
return self._peak_stats
267282

283+
@property
284+
def com(self) -> CentreOfMass:
285+
"""The centre of mass object containing ibex_bluesky_core's centre of mass."""
286+
if self._com is None:
287+
raise ValueError("centre of mass was not added as a callback.")
288+
return self._com
289+
268290
@property
269291
def subs(self) -> list[CallbackBase]:
270292
"""The list of subscribed callbacks."""
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import logging
2+
3+
import numpy as np
4+
from bluesky.callbacks import CollectThenCompute
5+
6+
from ibex_bluesky_core.utils import center_of_mass_of_area_under_curve
7+
8+
logger = logging.getLogger(__name__)
9+
10+
__all__ = ["CentreOfMass"]
11+
12+
13+
class CentreOfMass(CollectThenCompute):
14+
"""Compute centre of mass after a run finishes.
15+
16+
Calculates the CoM of the 2D region bounded by min(y), min(x), max(x),
17+
and straight-line segments joining (x, y) data points with their nearest
18+
neighbours along the x axis.
19+
"""
20+
21+
def __init__(self, x: str, y: str) -> None:
22+
"""Initialise the callback.
23+
24+
Args:
25+
x: Name of independent variable in event data
26+
y: Name of dependent variable in event data
27+
28+
"""
29+
super().__init__()
30+
self.x: str = x
31+
self.y: str = y
32+
self._result: float | None = None
33+
34+
@property
35+
def result(self) -> float | None:
36+
"""The centre-of-mass calculated by this callback."""
37+
return self._result
38+
39+
def compute(self) -> None:
40+
"""Calculate statistics at the end of the run."""
41+
x_values = []
42+
y_values = []
43+
44+
for event in self._events:
45+
if self.x not in event["data"]:
46+
raise ValueError(f"{self.x} is not in event document.")
47+
48+
if self.y not in event["data"]:
49+
raise ValueError(f"{self.y} is not in event document.")
50+
51+
x_values.append(event["data"][self.x])
52+
y_values.append(event["data"][self.y])
53+
54+
if not x_values:
55+
return
56+
57+
x_data = np.array(x_values, dtype=np.float64)
58+
y_data = np.array(y_values, dtype=np.float64)
59+
(self._result, _) = center_of_mass_of_area_under_curve(x_data, y_data)

src/ibex_bluesky_core/fitting.py

Lines changed: 3 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
"Trapezoid",
2727
]
2828

29+
from ibex_bluesky_core.utils import center_of_mass_of_area_under_curve
30+
2931

3032
class FitMethod:
3133
"""Tell LiveFit how to fit to a scan. Has a Model function and a Guess function.
@@ -545,49 +547,11 @@ def guess(
545547
return guess
546548

547549

548-
def _center_of_mass_and_mass(
549-
x: npt.NDArray[np.float64], y: npt.NDArray[np.float64]
550-
) -> tuple[float, float]:
551-
"""Compute the centre of mass of the area under a curve defined by a series of (x, y) points.
552-
553-
The "area under the curve" is a shape bounded by:
554-
- min(y), along the bottom edge
555-
- min(x), on the left-hand edge
556-
- max(x), on the right-hand edge
557-
- straight lines joining (x, y) data points to their nearest neighbours
558-
along the x-axis, along the top edge
559-
This is implemented by geometric decomposition of the shape into a series of trapezoids,
560-
which are further decomposed into rectangular and triangular regions.
561-
"""
562-
sort_indices = np.argsort(x, kind="stable")
563-
x = np.take_along_axis(x, sort_indices, axis=None)
564-
y = np.take_along_axis(y - np.min(y), sort_indices, axis=None)
565-
widths = np.diff(x)
566-
567-
# Area under the curve for two adjacent points is a right trapezoid.
568-
# Split that trapezoid into a rectangular region, plus a right triangle.
569-
# Find area and effective X CoM for each.
570-
rect_areas = widths * np.minimum(y[:-1], y[1:])
571-
rect_x_com = (x[:-1] + x[1:]) / 2.0
572-
triangle_areas = widths * np.abs(y[:-1] - y[1:]) / 2.0
573-
triangle_x_com = np.where(
574-
y[:-1] > y[1:], x[:-1] + (widths / 3.0), x[:-1] + (2.0 * widths / 3.0)
575-
)
576-
577-
total_area = np.sum(rect_areas + triangle_areas)
578-
if total_area == 0.0:
579-
# If all data was flat, return central x
580-
return (x[0] + x[-1]) / 2.0, 0
581-
582-
com = np.sum(rect_areas * rect_x_com + triangle_areas * triangle_x_com) / total_area
583-
return com, total_area
584-
585-
586550
def _guess_cen_and_width(
587551
x: npt.NDArray[np.float64], y: npt.NDArray[np.float64]
588552
) -> tuple[float, float]:
589553
"""Guess the center and width of a positive peak."""
590-
com, total_area = _center_of_mass_and_mass(x, y)
554+
com, total_area = center_of_mass_of_area_under_curve(x, y)
591555
y_range = np.max(y) - np.min(y)
592556
if y_range == 0.0:
593557
width = (np.max(x) - np.min(x)) / 2

src/ibex_bluesky_core/utils.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@
66
from typing import Any, Protocol
77

88
import matplotlib
9+
import numpy as np
10+
import numpy.typing as npt
911
import scipp as sc
1012
from bluesky.protocols import NamedMovable, Readable
1113

1214
__all__ = [
1315
"NamedReadableAndMovable",
1416
"calculate_polarisation",
17+
"center_of_mass_of_area_under_curve",
1518
"centred_pixel",
1619
"get_pv_prefix",
1720
"is_matplotlib_backend_qt",
@@ -52,6 +55,67 @@ class NamedReadableAndMovable(Readable[Any], NamedMovable[Any], Protocol):
5255
"""Abstract class for type checking that an object is readable, named and movable."""
5356

5457

58+
def center_of_mass_of_area_under_curve(
59+
x: npt.NDArray[np.float64], y: npt.NDArray[np.float64]
60+
) -> tuple[float, float]:
61+
"""Compute the centre of mass of the area under a curve defined by a series of (x, y) points.
62+
63+
The "area under the curve" is a shape bounded by:
64+
- min(y), along the bottom edge
65+
- min(x), on the left-hand edge
66+
- max(x), on the right-hand edge
67+
- straight lines joining (x, y) data points to their nearest neighbours
68+
along the x-axis, along the top edge
69+
70+
This is implemented by geometric decomposition of the shape into a series of trapezoids,
71+
which are further decomposed into rectangular and triangular regions.
72+
73+
Returns a tuple of the centre of mass and the total area under the curve.
74+
75+
"""
76+
# Sorting here avoids special-cases with disordered points, which may occur
77+
# from a there-and-back scan, or from an adaptive scan.
78+
sort_indices = np.argsort(x, kind="stable")
79+
x = np.take_along_axis(x, sort_indices, axis=None)
80+
y = np.take_along_axis(y - np.min(y), sort_indices, axis=None)
81+
82+
# If the data points are "fence-posts", this calculates the x width of
83+
# each "fence panel".
84+
widths = np.diff(x)
85+
86+
# Area under the curve for two adjacent points is a right trapezoid.
87+
# Split that trapezoid into a rectangular region, plus a right triangle.
88+
# Find area and effective X CoM for each.
89+
90+
# We want the area of the rectangular part of the right trapezoid.
91+
# This is width * [height of either left or right point, whichever is lowest]
92+
rect_areas = widths * np.minimum(y[:-1], y[1:])
93+
# CoM of a rectangle in x is simply the average x.
94+
rect_x_com = (x[:-1] + x[1:]) / 2.0
95+
96+
# Now the area of the triangular part of the right trapezoid - this is
97+
# width * height / 2, where height is the absolute difference between the
98+
# two y values.
99+
triangle_areas = widths * np.abs(y[:-1] - y[1:]) / 2.0
100+
# CoM of a right triangle is 1/3 along the base, from the right angle
101+
# y[:-1] > y[1:] is true if y_[n] > y_[n+1], (i.e. if the right angle is on the
102+
# left-hand side of the triangle).
103+
# If that's true, the CoM lies 1/3 of the way along the x axis
104+
# Otherwise, the CoM lies 2/3 of the way along the x axis (1/3 from the right angle)
105+
triangle_x_com = np.where(
106+
y[:-1] > y[1:], x[:-1] + (widths / 3.0), x[:-1] + (2.0 * widths / 3.0)
107+
)
108+
109+
total_area = np.sum(rect_areas + triangle_areas)
110+
if total_area == 0.0:
111+
# If all data was flat, return central x
112+
return (x[0] + x[-1]) / 2.0, total_area
113+
114+
return np.sum(
115+
rect_areas * rect_x_com + triangle_areas * triangle_x_com
116+
) / total_area, total_area
117+
118+
55119
def calculate_polarisation(
56120
a: sc.Variable | sc.DataArray, b: sc.Variable | sc.DataArray
57121
) -> sc.Variable | sc.DataArray:

0 commit comments

Comments
 (0)