|
20 | 20 | "Gaussian", |
21 | 21 | "Linear", |
22 | 22 | "Lorentzian", |
| 23 | + "NegativeTrapezoid", |
23 | 24 | "Polynomial", |
24 | 25 | "SlitScan", |
25 | 26 | "TopHat", |
@@ -475,6 +476,57 @@ def guess( |
475 | 476 | return guess |
476 | 477 |
|
477 | 478 |
|
| 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 | + |
478 | 530 | class TopHat(Fit): |
479 | 531 | """Top Hat Fitting.""" |
480 | 532 |
|
@@ -502,26 +554,31 @@ def guess( |
502 | 554 | def guess( |
503 | 555 | x: npt.NDArray[np.float64], y: npt.NDArray[np.float64] |
504 | 556 | ) -> 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) |
512 | 558 |
|
513 | 559 | 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)), |
518 | 567 | } |
519 | 568 |
|
520 | 569 | return init_guess |
521 | 570 |
|
522 | 571 | return guess |
523 | 572 |
|
524 | 573 |
|
| 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 | + |
525 | 582 | class Trapezoid(Fit): |
526 | 583 | """Trapezoid Fitting.""" |
527 | 584 |
|
@@ -557,37 +614,74 @@ def guess( |
557 | 614 | def guess( |
558 | 615 | x: npt.NDArray[np.float64], y: npt.NDArray[np.float64] |
559 | 616 | ) -> 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) |
562 | 619 |
|
563 | | - cen = np.mean(x) |
| 620 | + height = np.max(y) - np.min(y) |
564 | 621 | background = np.min(y) |
565 | | - height = np.max(y) - background |
| 622 | + y_offset = gradient_guess * width / 2.0 |
566 | 623 |
|
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 | + } |
573 | 631 |
|
574 | | - x0 = 0.5 * (np.min(x) + x1) # Guess that x0 is half way between min(x) and x1 |
| 632 | + return init_guess |
575 | 633 |
|
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.""" |
580 | 647 |
|
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 |
584 | 678 |
|
585 | 679 | 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), |
588 | 682 | "height": lmfit.Parameter("height", height, min=0), |
589 | 683 | "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), |
591 | 685 | } |
592 | 686 |
|
593 | 687 | return init_guess |
|
0 commit comments