Skip to content

Commit e581c06

Browse files
authored
Merge pull request #186 from ISISComputingGroup/negative_trapezoids
Negative trapezoids
2 parents 9099a5c + a3ae880 commit e581c06

File tree

3 files changed

+263
-37
lines changed

3 files changed

+263
-37
lines changed

doc/fitting/standard_fits.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,4 +160,26 @@ g(x) = \max(f(x), \text{background})
160160
y = \min(g(x), \text{background} + \text{height})
161161
```
162162

163-
![TrapezoidModel](../_static/images_fits/trapezoid.png)
163+
![TrapezoidModel](../_static/images_fits/trapezoid.png)
164+
165+
## Negative Trapezoid
166+
167+
API Reference: [`NegativeTrapezoid`](ibex_bluesky_core.fitting.NegativeTrapezoid)
168+
169+
This model is the same shape as the trapezoid described above, but with a negative height.s
170+
171+
- `cen` - The centre (x) of the model
172+
- `gradient` - How steep the edges of the trapezoid are
173+
- `height` - The maximum height of the model below `background`
174+
- `background` - The maximum value (y) of the model
175+
- `y_offset` - Acts as a width factor for the trapezoid. If you extrapolate the sides of the trapezoid until they meet, this value represents the y coord of this point minus height and background.
176+
177+
```{math}
178+
f(x) = \text{y_offset} - \text{height} + \text{background} + \text{gradient} * |x - \text{cen}|
179+
```
180+
```{math}
181+
g(x) = \max(f(x), \text{background} - \text{height})
182+
```
183+
```{math}
184+
y = \min(g(x), \text{background})
185+
```

src/ibex_bluesky_core/fitting.py

Lines changed: 126 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"Gaussian",
2121
"Linear",
2222
"Lorentzian",
23+
"NegativeTrapezoid",
2324
"Polynomial",
2425
"SlitScan",
2526
"TopHat",
@@ -475,6 +476,57 @@ def guess(
475476
return guess
476477

477478

479+
def _center_of_mass_and_mass(
480+
x: npt.NDArray[np.float64], y: npt.NDArray[np.float64]
481+
) -> tuple[float, float]:
482+
"""Compute the centre of mass of the area under a curve defined by a series of (x, y) points.
483+
484+
The "area under the curve" is a shape bounded by:
485+
- min(y), along the bottom edge
486+
- min(x), on the left-hand edge
487+
- max(x), on the right-hand edge
488+
- straight lines joining (x, y) data points to their nearest neighbours
489+
along the x-axis, along the top edge
490+
This is implemented by geometric decomposition of the shape into a series of trapezoids,
491+
which are further decomposed into rectangular and triangular regions.
492+
"""
493+
sort_indices = np.argsort(x, kind="stable")
494+
x = np.take_along_axis(x, sort_indices, axis=None)
495+
y = np.take_along_axis(y - np.min(y), sort_indices, axis=None)
496+
widths = np.diff(x)
497+
498+
# Area under the curve for two adjacent points is a right trapezoid.
499+
# Split that trapezoid into a rectangular region, plus a right triangle.
500+
# Find area and effective X CoM for each.
501+
rect_areas = widths * np.minimum(y[:-1], y[1:])
502+
rect_x_com = (x[:-1] + x[1:]) / 2.0
503+
triangle_areas = widths * np.abs(y[:-1] - y[1:]) / 2.0
504+
triangle_x_com = np.where(
505+
y[:-1] > y[1:], x[:-1] + (widths / 3.0), x[:-1] + (2.0 * widths / 3.0)
506+
)
507+
508+
total_area = np.sum(rect_areas + triangle_areas)
509+
if total_area == 0.0:
510+
# If all data was flat, return central x
511+
return (x[0] + x[-1]) / 2.0, 0
512+
513+
com = np.sum(rect_areas * rect_x_com + triangle_areas * triangle_x_com) / total_area
514+
return com, total_area
515+
516+
517+
def _guess_cen_and_width(
518+
x: npt.NDArray[np.float64], y: npt.NDArray[np.float64]
519+
) -> tuple[float, float]:
520+
"""Guess the center and width of a positive peak."""
521+
com, total_area = _center_of_mass_and_mass(x, y)
522+
y_range = np.max(y) - np.min(y)
523+
if y_range == 0.0:
524+
width = (np.max(x) - np.min(x)) / 2
525+
else:
526+
width = total_area / y_range
527+
return com, width
528+
529+
478530
class TopHat(Fit):
479531
"""Top Hat Fitting."""
480532

@@ -502,26 +554,31 @@ def guess(
502554
def guess(
503555
x: npt.NDArray[np.float64], y: npt.NDArray[np.float64]
504556
) -> dict[str, lmfit.Parameter]:
505-
top = np.where(y > np.mean(y))[0]
506-
# Guess that any value above the mean is the top part
507-
508-
if len(top) > 0:
509-
width = x[np.max(top)] - x[np.min(top)]
510-
else:
511-
width = (max(x) - min(x)) / 2
557+
cen, width = _guess_cen_and_width(x, y)
512558

513559
init_guess = {
514-
"cen": lmfit.Parameter("cen", np.mean(x)),
515-
"width": lmfit.Parameter("width", width),
516-
"height": lmfit.Parameter("height", (max(y) - min(y))),
517-
"background": lmfit.Parameter("background", min(y)),
560+
"cen": lmfit.Parameter("cen", cen),
561+
"width": lmfit.Parameter("width", width, min=0),
562+
"height": lmfit.Parameter(
563+
"height",
564+
np.max(y) - np.min(y),
565+
),
566+
"background": lmfit.Parameter("background", np.min(y)),
518567
}
519568

520569
return init_guess
521570

522571
return guess
523572

524573

574+
def _guess_trapezoid_gradient(x: npt.NDArray[np.float64], y: npt.NDArray[np.float64]) -> float:
575+
gradients = np.zeros_like(x[1:], dtype=np.float64)
576+
x_diffs = x[:-1] - x[1:]
577+
y_diffs = y[:-1] - y[1:]
578+
np.divide(y_diffs, x_diffs, out=gradients, where=x_diffs != 0)
579+
return np.max(np.abs(gradients))
580+
581+
525582
class Trapezoid(Fit):
526583
"""Trapezoid Fitting."""
527584

@@ -557,37 +614,74 @@ def guess(
557614
def guess(
558615
x: npt.NDArray[np.float64], y: npt.NDArray[np.float64]
559616
) -> dict[str, lmfit.Parameter]:
560-
top = np.where(y > np.mean(y))[0]
561-
# Guess that any value above the y mean is the top part
617+
cen, width = _guess_cen_and_width(x, y)
618+
gradient_guess = _guess_trapezoid_gradient(x, y)
562619

563-
cen = np.mean(x)
620+
height = np.max(y) - np.min(y)
564621
background = np.min(y)
565-
height = np.max(y) - background
622+
y_offset = gradient_guess * width / 2.0
566623

567-
if top.size > 0:
568-
i = np.min(top)
569-
x1 = x[i] # x1 is the left of the top part
570-
else:
571-
width_top = (np.max(x) - np.min(x)) / 2
572-
x1 = cen - width_top / 2
624+
init_guess = {
625+
"cen": lmfit.Parameter("cen", cen, min=np.min(x), max=np.max(x)),
626+
"gradient": lmfit.Parameter("gradient", gradient_guess, min=0),
627+
"height": lmfit.Parameter("height", height, min=0),
628+
"background": lmfit.Parameter("background", background),
629+
"y_offset": lmfit.Parameter("y_offset", y_offset),
630+
}
573631

574-
x0 = 0.5 * (np.min(x) + x1) # Guess that x0 is half way between min(x) and x1
632+
return init_guess
575633

576-
if height == 0.0:
577-
gradient = 0.0
578-
else:
579-
gradient = height / (x1 - x0)
634+
return guess
635+
636+
637+
class NegativeTrapezoid(Fit):
638+
"""Negative Trapezoid Fitting."""
639+
640+
equation = """
641+
y = clip(y_offset - height + background + gradient * abs(x - cen),
642+
background - height, background)"""
643+
644+
@classmethod
645+
def model(cls, *args: int) -> lmfit.Model:
646+
"""Negative Trapezoid Model."""
580647

581-
y_intercept0 = np.max(y) - gradient * x1 # To find the slope function
582-
y_tip = gradient * cen + y_intercept0
583-
y_offset = y_tip - height - background
648+
def model(
649+
x: npt.NDArray[np.float64],
650+
cen: float,
651+
gradient: float,
652+
height: float,
653+
background: float,
654+
y_offset: float, # Acts as a width multiplier
655+
) -> npt.NDArray[np.float64]:
656+
y = y_offset - height + background + gradient * np.abs(x - cen)
657+
y = np.maximum(y, background - height)
658+
y = np.minimum(y, background)
659+
return y
660+
661+
return lmfit.Model(model, name=f"{cls.__name__} [{cls.equation}]")
662+
663+
@classmethod
664+
def guess(
665+
cls, *args: int
666+
) -> Callable[[npt.NDArray[np.float64], npt.NDArray[np.float64]], dict[str, lmfit.Parameter]]:
667+
"""Negative Trapezoid Guessing."""
668+
669+
def guess(
670+
x: npt.NDArray[np.float64], y: npt.NDArray[np.float64]
671+
) -> dict[str, lmfit.Parameter]:
672+
cen, width = _guess_cen_and_width(x, -y)
673+
gradient_guess = _guess_trapezoid_gradient(x, y)
674+
675+
height = np.max(y) - np.min(y)
676+
background = np.max(y)
677+
y_offset = -gradient_guess * width / 2.0
584678

585679
init_guess = {
586-
"cen": lmfit.Parameter("cen", cen),
587-
"gradient": lmfit.Parameter("gradient", gradient, min=0),
680+
"cen": lmfit.Parameter("cen", cen, min=np.min(x), max=np.max(x)),
681+
"gradient": lmfit.Parameter("gradient", gradient_guess, min=0),
588682
"height": lmfit.Parameter("height", height, min=0),
589683
"background": lmfit.Parameter("background", background),
590-
"y_offset": lmfit.Parameter("y_offset", y_offset, min=0),
684+
"y_offset": lmfit.Parameter("y_offset", y_offset),
591685
}
592686

593687
return init_guess

tests/callbacks/fitting/test_fitting_methods.py

Lines changed: 114 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
Gaussian,
1818
Linear,
1919
Lorentzian,
20+
NegativeTrapezoid,
2021
Polynomial,
2122
SlitScan,
2223
TopHat,
@@ -599,7 +600,7 @@ def test_width_guess(self):
599600

600601
outp = TopHat.guess()(x, y)
601602

602-
assert outp["width"] == pytest.approx(1.0, rel=1e-2)
603+
assert outp["width"] == pytest.approx(2.0, rel=1e-2)
603604

604605
def test_guess_given_flat_data(self):
605606
x = np.arange(-5.0, 5.0, 1.0, dtype=np.float64)
@@ -688,15 +689,15 @@ def test_gradient_guess(self):
688689

689690
outp = Trapezoid.guess()(x, y)
690691

691-
assert outp["gradient"] == pytest.approx(10.0, rel=1e-2)
692+
assert outp["gradient"] == pytest.approx(8.0, rel=1e-2)
692693

693694
def test_y_offset_guess(self):
694-
x = np.arange(-5.0, 5.0, 1.0, dtype=np.float64)
695+
x = np.linspace(-5.0, 5.0, num=11, dtype=np.float64)
695696
y = np.array([1.0, 1.0, 1.0, 2.0, 3.0, 3.0, 3.0, 2.0, 1.0, 1.0, 1.0], dtype=np.float64)
696697

697698
outp = Trapezoid.guess()(x, y)
698699

699-
x1 = np.arange(-6.0, 6.0, 1.0, dtype=np.float64)
700+
x1 = np.linspace(-6.0, 6.0, num=13, dtype=np.float64)
700701
y1 = np.array(
701702
[1.0, 1.0, 1.0, 2.0, 3.0, 3.0, 3.0, 3.0, 3.0, 2.0, 1.0, 1.0, 1.0], dtype=np.float64
702703
)
@@ -713,3 +714,112 @@ def test_guess_given_flat_data(self):
713714
outp = Trapezoid.guess()(x, y)
714715
# check that with flat data gradient guess is 0
715716
assert outp["gradient"] == pytest.approx(0.0, rel=1e-2)
717+
718+
719+
class TestNegativeTrapezoid:
720+
class TestNegativeTrapezoidModel:
721+
def test_negative_trapezoid_model(self):
722+
x = np.arange(-5.0, 6.0, 1.0, dtype=np.float64)
723+
cen = 0
724+
y_offset = -1
725+
height = 1
726+
background = 1
727+
gradient = 1
728+
729+
outp = NegativeTrapezoid.model().func(
730+
x,
731+
cen=cen,
732+
y_offset=y_offset,
733+
height=height,
734+
background=background,
735+
gradient=gradient,
736+
)
737+
738+
assert background == pytest.approx(np.max(outp), rel=1e-2)
739+
assert background - height == pytest.approx(np.min(outp), rel=1e-2)
740+
741+
outp1 = NegativeTrapezoid.model().func(
742+
x,
743+
cen=cen + 3,
744+
y_offset=y_offset,
745+
height=height,
746+
background=background,
747+
gradient=gradient - 0.5,
748+
)
749+
750+
# check centre moves when data is shifted
751+
assert np.mean(x[np.where(outp < background)]) < np.mean(
752+
x[np.where(outp1 < background)]
753+
)
754+
755+
# check gradient: a greater gradient means smaller average y values as wider
756+
assert np.mean(outp) > np.mean(outp1)
757+
758+
outp2 = NegativeTrapezoid.model().func(
759+
x,
760+
cen=cen,
761+
y_offset=y_offset - 5,
762+
height=height,
763+
background=background,
764+
gradient=gradient,
765+
)
766+
767+
# check y_offset: a smaller y_offset means smaller average y values as wider
768+
assert np.mean(outp) > np.mean(outp2)
769+
770+
class TestNegativeTrapezoidGuess:
771+
def test_background_guess(self):
772+
x = np.array([-1.0, 0.0, 1.0, 2.0, 3.0], dtype=np.float64)
773+
y = np.array([-1.0, -2.0, -2.0, -2.0, -1.0], dtype=np.float64)
774+
775+
outp = NegativeTrapezoid.guess()(x, y)
776+
777+
assert outp["background"] == pytest.approx(-1.0, rel=1e-2)
778+
779+
def test_cen_height_guess(self):
780+
x = np.array([-1.0, 0.0, 1.0, 2.0, 3.0], dtype=np.float64)
781+
y = np.array([-1.0, -1.0, -2.0, -1.0, -1.0], dtype=np.float64)
782+
783+
outp = NegativeTrapezoid.guess()(x, y)
784+
785+
assert outp["cen"] == pytest.approx(1.0, rel=1e-2)
786+
assert outp["height"] == pytest.approx(1.0, rel=1e-2)
787+
788+
def test_gradient_guess(self):
789+
x = np.array([-4.0, -3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0], dtype=np.float64)
790+
y = np.array([1.0, 2.0, 4.0, 8.0, 16.0, 8.0, 4.0, 2.0, 1.0], dtype=np.float64)
791+
792+
# Should choose x = -1.0 as x1 and x = -2.5 as x0
793+
# height = 16 - 1 = 15
794+
# gradient = 15 / (-1 - -2.5) = 10
795+
796+
outp = NegativeTrapezoid.guess()(x, y)
797+
798+
assert outp["gradient"] == pytest.approx(8.0, rel=1e-2)
799+
800+
def test_y_offset_guess(self):
801+
x = np.linspace(-5.0, 5.0, num=11, dtype=np.float64)
802+
y = np.array(
803+
[-1.0, -1.0, -1.0, -2.0, -3.0, -3.0, -3.0, -2.0, -1.0, -1.0, -1.0], dtype=np.float64
804+
)
805+
806+
outp = NegativeTrapezoid.guess()(x, y)
807+
808+
x1 = np.linspace(-6.0, 6.0, num=13, dtype=np.float64)
809+
y1 = np.array(
810+
[-1.0, -1.0, -1.0, -2.0, -3.0, -3.0, -3.0, -3.0, -3.0, -2.0, -1.0, -1.0, -1.0],
811+
dtype=np.float64,
812+
)
813+
814+
outp1 = NegativeTrapezoid.guess()(x1, y1)
815+
816+
# Assert that with a greater top width, y_offset decreases
817+
assert outp["y_offset"] > outp1["y_offset"]
818+
819+
def test_guess_given_flat_data(self):
820+
x = np.arange(-5.0, 5.0, 1.0, dtype=np.float64)
821+
y = np.zeros_like(x, dtype=np.float64) + 1
822+
823+
outp = NegativeTrapezoid.guess()(x, y)
824+
# check that with flat data gradient guess is 0
825+
assert outp["gradient"] == pytest.approx(0.0, rel=1e-2)

0 commit comments

Comments
 (0)