Skip to content

Commit 1c2e034

Browse files
committed
Trapezes
1 parent a4795d3 commit 1c2e034

File tree

2 files changed

+93
-130
lines changed

2 files changed

+93
-130
lines changed

src/ibex_bluesky_core/callbacks/fitting/__init__.py

Lines changed: 35 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -132,36 +132,44 @@ def update_fit(self) -> None:
132132

133133

134134
def center_of_mass(x: npt.NDArray[np.float64], y: npt.NDArray[np.float64]) -> float:
135-
"""Compute our own centre of mass.
135+
"""Compute the centre of mass of the area under a curve.
136+
137+
The "area under the curve" is a shape bounded by:
138+
- Straight line segments joining (x, y) data points, along the top edge
139+
- min(y), at the bottom
140+
- min(x), on the left-hand side
141+
- max(x), on the right-hand side
136142
137143
Follow these rules:
138-
Background does not skew CoM
139-
Order of data does not skew CoM
140-
Non constant point spacing does not skew CoM
141-
Assumes that the peak is positive
144+
- Positive or negative background does not skew CoM
145+
- Order of data does not skew CoM
146+
- Non constant point spacing does not skew CoM
147+
- Always calculates CoM of the area *under* the curve, regardless of sign of Y data.
142148
"""
143-
# Offset points for any background
144-
# Sort points in terms of x
145-
arg_sorted = np.argsort(x)
146-
x_sorted = np.take_along_axis(x, arg_sorted, axis=None)
147-
y_sorted = np.take_along_axis(y - np.min(y), arg_sorted, axis=None)
148-
149-
# Each point has its own weight given by its distance to its neighbouring point
150-
# Edge cases are calculated as x_1 - x_0 and x_-1 - x_-2
151-
152-
x_diff = np.diff(x_sorted)
153-
154-
weight = np.array([x_diff[0]])
155-
weight = np.append(weight, (x_diff[1:] + x_diff[:-1]) / 2)
156-
weight = np.append(weight, [x_diff[-1]])
157-
158-
weight /= np.max(weight) # Normalise weights in terms of max(weights)
159-
160-
sum_xyw = np.sum(x_sorted * y_sorted * weight) # Weighted CoM calculation
161-
sum_yw = np.sum(y_sorted * weight)
162-
com_x = sum_xyw / sum_yw
163-
164-
return com_x
149+
# Ensure we do not take CoM of negative masses, sort for ascending X data.
150+
sort_indices = np.argsort(x, kind="stable")
151+
x = np.take_along_axis(x, sort_indices, axis=None)
152+
y = np.take_along_axis(y - np.min(y), sort_indices, axis=None)
153+
154+
widths = np.diff(x)
155+
156+
# Area under the curve for two adjacent points is a right trapezoid.
157+
# Split that trapezoid into a rectangular region, plus a right triangle.
158+
# Find area and effective X CoM for each.
159+
rect_areas = widths * np.minimum(y[:-1], y[1:])
160+
rect_x_com = (x[:-1] + x[1:]) / 2.0
161+
triangle_areas = widths * np.abs(y[:-1] - y[1:]) / 2.0
162+
triangle_x_com = np.where(
163+
y[:-1] > y[1:], x[:-1] + (widths / 3.0), x[:-1] + (2.0 * widths / 3.0)
164+
)
165+
166+
if np.sum(rect_areas + triangle_areas) == 0.0:
167+
# If all data was flat, return central x
168+
return (x[0] + x[-1]) / 2.0
169+
170+
return np.sum(rect_areas * rect_x_com + triangle_areas * triangle_x_com) / np.sum(
171+
rect_areas + triangle_areas
172+
)
165173

166174

167175
@make_class_safe(logger=logger) # pyright: ignore (pyright doesn't understand this decorator)
Lines changed: 58 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,114 +1,69 @@
1-
import numpy as np
2-
import numpy.typing as npt
31
import pytest
42

53
from ibex_bluesky_core.callbacks.fitting import PeakStats
64

7-
# Tests:
8-
# Test with normal scan with gaussian data
9-
# Check that asymmetrical data does not skew CoM
10-
# Check that having a background on data does not skew CoM
11-
# Check that order of documents does not skew CoM
12-
# Check that point spacing does not skew CoM
13-
14-
15-
def gaussian(
16-
x: npt.NDArray[np.float64], amp: float, sigma: float, x0: float, bg: float
17-
) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]:
18-
return (x, amp * np.exp(-((x - x0) ** 2) / (2 * sigma**2)) + bg)
19-
20-
21-
def simulate_run_and_return_com(xy: tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]):
22-
ps = PeakStats("x", "y")
23-
24-
ps.start({}) # pyright: ignore
25-
26-
for x, y in np.vstack(xy).T:
27-
ps.event({"data": {"x": x, "y": y}}) # pyright: ignore
28-
29-
ps.stop({}) # pyright: ignore
30-
31-
return ps["com"]
32-
33-
34-
@pytest.mark.parametrize(
35-
("x", "amp", "sigma", "x0", "bg"),
36-
[
37-
(np.arange(-2, 3), 1, 1, 0, 0),
38-
(np.arange(-4, 1), 1, 1, -2, 0),
39-
],
40-
)
41-
def test_normal_scan(x: npt.NDArray[np.float64], amp: float, sigma: float, x0: float, bg: float):
42-
xy = gaussian(x, amp, sigma, x0, bg)
43-
com = simulate_run_and_return_com(xy)
44-
assert com == pytest.approx(x0, abs=1e-4)
45-
465

476
@pytest.mark.parametrize(
48-
("x", "amp", "sigma", "x0", "bg"),
7+
("data", "expected_com"),
498
[
50-
(np.arange(-4, 10), 1, 1, 0, 0),
51-
(np.arange(-6, 20), 1, 1, -2, 0),
9+
# Simplest case:
10+
# - Flat, non-zero Y data
11+
# - Evenly spaced, monotonically increasing X data
12+
([(0, 1), (2, 1), (4, 1), (6, 1), (8, 1), (10, 1)], 5.0),
13+
# Simple triangular peak
14+
([(0, 0), (1, 1), (2, 2), (3, 1), (4, 0), (5, 0)], 2.0),
15+
# Simple triangular peak with non-zero base
16+
([(0, 10), (1, 11), (2, 12), (3, 11), (4, 10), (5, 10)], 2.0),
17+
# No data at all
18+
([], None),
19+
# Only one point, com should be at that one point regardless whether it
20+
# measured zero or some other y value.
21+
([(5, 0)], 5.0),
22+
([(5, 50)], 5.0),
23+
# Two points, flat data, com should be in the middle
24+
([(0, 5), (10, 5)], 5.0),
25+
# Flat, logarithmically spaced data - CoM should be in centre of measured range.
26+
([(1, 3), (10, 3), (100, 3), (1000, 3), (10000, 3)], 5000.5),
27+
# "triangle" defined by area under two points
28+
# (CoM of a right triangle is 1/3 along x from right angle)
29+
([(0, 0), (3, 6)], 2.0),
30+
([(0, 6), (3, 0)], 1.0),
31+
# Cases with the first/last points not having equal spacings with each other
32+
([(0, 1), (0.1, 1), (4, 1), (5, 0), (6, 1), (10, 1)], 5.0),
33+
([(0, 1), (4, 1), (5, 0), (6, 1), (9.9, 1), (10, 1)], 5.0),
34+
# Two triangular peaks next to each other, with different point spacings
35+
# but same shapes, over a base of zero.
36+
([(0, 0), (1, 1), (2, 2), (3, 1), (4, 0), (6, 2), (8, 0), (10, 0)], 4.0),
37+
([(0, 0), (2, 2), (4, 0), (5, 1), (6, 2), (7, 1), (8, 0), (10, 0)], 4.0),
38+
# Two triangular peaks next to each other, with different point spacings
39+
# but same shapes, over a base of 10.
40+
([(0, 10), (1, 11), (2, 12), (3, 11), (4, 10), (6, 12), (8, 10), (10, 10)], 4.0),
41+
([(0, 10), (2, 12), (4, 10), (5, 11), (6, 12), (7, 11), (8, 10), (10, 10)], 4.0),
42+
# "Narrow" peak over a base of 0
43+
([(0, 0), (4.999, 0), (5.0, 10), (5.001, 0)], 5.0),
44+
# "Narrow" peak as above, over a base of 10 (y translation should not
45+
# affect CoM)
46+
([(0, 10), (4.999, 10), (5.0, 20), (5.001, 10)], 5.0),
47+
# Non-monotonically increasing x data (e.g. from adaptive scan)
48+
([(0, 0), (2, 2), (1, 1), (3, 1), (4, 0)], 2.0),
49+
# Overscanned data (all measurements duplicated, e.g. there-and-back scan)
50+
([(0, 0), (1, 1), (2, 0), (2, 0), (1, 1), (0, 0)], 1.0),
51+
# Mixed positive/negative data. This explicitly calculates area *under* curve,
52+
# so CoM should still be the CoM of the positive peak in this data.
53+
([(0, -1), (1, 0), (2, -1), (3, -1)], 1.0),
54+
# Y data with a single positive peak, which happens
55+
# to sum to zero but never contains zero.
56+
([(0, -1), (1, 3), (2, -1), (3, -1)], 1.0),
57+
# Y data which happens to sum to *nearly* zero
58+
([(0, -1), (1, 3.000001), (2, -1), (3, -1)], 1.0),
5259
],
5360
)
54-
def test_asymmetrical_scan(
55-
x: npt.NDArray[np.float64], amp: float, sigma: float, x0: float, bg: float
56-
):
57-
xy = gaussian(x, amp, sigma, x0, bg)
58-
com = simulate_run_and_return_com(xy)
59-
assert com == pytest.approx(x0, abs=1e-4)
60-
61-
62-
@pytest.mark.parametrize(
63-
("x", "amp", "sigma", "x0", "bg"),
64-
[
65-
(np.arange(-2, 3), 1, 1, 0, 3),
66-
(np.arange(-4, 1), 1, 1, -2, -0.5),
67-
(np.arange(-4, 1), 1, 1, -2, -3),
68-
],
69-
)
70-
def test_background_gaussian_scan(
71-
x: npt.NDArray[np.float64], amp: float, sigma: float, x0: float, bg: float
72-
):
73-
xy = gaussian(x, amp, sigma, x0, bg)
74-
com = simulate_run_and_return_com(xy)
75-
assert com == pytest.approx(x0, abs=1e-4)
76-
77-
78-
@pytest.mark.parametrize(
79-
("x", "amp", "sigma", "x0", "bg"),
80-
[
81-
(np.array([0, -2, 2, -1, 1]), 1, 1, 0, 0),
82-
(np.array([-4, 0, -2, -3, -1]), 1, 1, -2, 0),
83-
],
84-
)
85-
def test_non_continuous_scan(
86-
x: npt.NDArray[np.float64], amp: float, sigma: float, x0: float, bg: float
87-
):
88-
xy = gaussian(x, amp, sigma, x0, bg)
89-
com = simulate_run_and_return_com(xy)
90-
assert com == pytest.approx(x0, abs=1e-4)
61+
def test_compute_com(data: list[tuple[float, float]], expected_com):
62+
ps = PeakStats("x", "y")
63+
ps.start({}) # pyright: ignore
9164

65+
for x, y in data:
66+
ps.event({"data": {"x": x, "y": y}}) # pyright: ignore
9267

93-
@pytest.mark.parametrize(
94-
("x", "amp", "sigma", "x0", "bg"),
95-
[
96-
(np.append(np.arange(-10, -2, 0.05), np.arange(-2, 4, 0.5)), 1, 0.5, 0, 0),
97-
(
98-
np.concatenate(
99-
(np.arange(-5, -2.0, 0.5), np.arange(-2.5, -1.45, 0.05), np.arange(-1.5, 1, 0.5)),
100-
axis=0,
101-
),
102-
1,
103-
0.25,
104-
0,
105-
0,
106-
),
107-
],
108-
)
109-
def test_non_constant_point_spacing_scan(
110-
x: npt.NDArray[np.float64], amp: float, sigma: float, x0: float, bg: float
111-
):
112-
xy = gaussian(x, amp, sigma, x0, bg)
113-
com = simulate_run_and_return_com(xy)
114-
assert com == pytest.approx(x0, abs=1e-3)
68+
ps.stop({}) # pyright: ignore
69+
assert ps["com"] == pytest.approx(expected_com)

0 commit comments

Comments
 (0)