|
6 | 6 | from typing import Any, Protocol |
7 | 7 |
|
8 | 8 | import matplotlib |
| 9 | +import numpy as np |
| 10 | +import numpy.typing as npt |
9 | 11 | import scipp as sc |
10 | 12 | from bluesky.protocols import NamedMovable, Readable |
11 | 13 |
|
12 | 14 | __all__ = [ |
13 | 15 | "NamedReadableAndMovable", |
14 | 16 | "calculate_polarisation", |
| 17 | + "center_of_mass_of_area_under_curve", |
15 | 18 | "centred_pixel", |
16 | 19 | "get_pv_prefix", |
17 | 20 | "is_matplotlib_backend_qt", |
@@ -52,6 +55,67 @@ class NamedReadableAndMovable(Readable[Any], NamedMovable[Any], Protocol): |
52 | 55 | """Abstract class for type checking that an object is readable, named and movable.""" |
53 | 56 |
|
54 | 57 |
|
| 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 | + |
55 | 119 | def calculate_polarisation( |
56 | 120 | a: sc.Variable | sc.DataArray, b: sc.Variable | sc.DataArray |
57 | 121 | ) -> sc.Variable | sc.DataArray: |
|
0 commit comments