Repository: usnistgov/mass

Issue:
Mass calibration objects should assess whether their gain curve is monotonic
We need an easy way to verify monotonic energy calibration functions, so build that in to `EnergyCalibration`.

Discussion:

Patch:
diff --git a/VERSIONS.md b/VERSIONS.md
index 7adda178..c3f07c76 100644
--- a/VERSIONS.md
+++ b/VERSIONS.md
@@ -7,6 +7,7 @@
 * Fix Ruff errors.
 * Allow setting channels bad when in noise-only mode (issue 301).
 * Make `plot_noise()` have the right x-axis (frequency) values (issue 303).
+* Add `EnergyCalibration.ismonotonic()` and test it (issue 305).
 
 **0.8.4** June 5, 2024
 
diff --git a/mass/calibration/energy_calibration.py b/mass/calibration/energy_calibration.py
index b7c3821a..bef0378f 100644
--- a/mass/calibration/energy_calibration.py
+++ b/mass/calibration/energy_calibration.py
@@ -411,6 +411,14 @@ class EnergyCalibration:  # noqa: PLR0904
     def cal_point_names(self):
         return self._names
 
+    @property
+    def ismonotonic(self):
+        "Is the curve monotonic from 0 to 1.05 times the max anchor point's pulse height?"
+        npoints = 1001
+        ph = np.linspace(0, 1.05 * self._ph.max(), npoints)
+        e = self(ph)
+        return np.all(np.diff(e) > 0)
+
     def _update_converters(self):
         """There is now one (or more) new data points. All the math goes on in this method."""
         # Sort in ascending energy order
diff --git a/mass/core/channel_group.py b/mass/core/channel_group.py
index 28fd4d52..16cb0840 100644
--- a/mass/core/channel_group.py
+++ b/mass/core/channel_group.py
@@ -1134,15 +1134,15 @@ class TESGroup(CutFieldMixin, GroupLooper):  # noqa: PLR0904, PLR0917
                 df = ds.noise_psd.attrs['delta_f']
                 freq = np.arange(len(yvalue)) * df
                 if include_dc:
-                    freq[0] = freq[1]*0.1
+                    freq[0] = freq[1] * 0.1
                     axis.plot(freq, yvalue, label=f'Chan {channum}',
                             color=cmap(float(i) / nplot))
-                    fmin = min(fmin, freq[0]*0.8)
+                    fmin = min(fmin, freq[0] * 0.8)
                 else:
                     axis.plot(freq[1:], yvalue[1:], label=f'Chan {channum}',
                             color=cmap(float(i) / nplot))
-                    fmin = min(fmin, freq[1]*0.8)
-                fmax = max(fmax, freq[-1]*1.2)
+                    fmin = min(fmin, freq[1] * 0.8)
+                fmax = max(fmax, freq[-1] * 1.2)
             except Exception:
                 LOG.warning("WARNING: Could not plot channel %4d.", channum)
                 continue


Test patch:
diff --git a/tests/calibration/test_calibration.py b/tests/calibration/test_calibration.py
index 31714b2b..cf6e114e 100755
--- a/tests/calibration/test_calibration.py
+++ b/tests/calibration/test_calibration.py
@@ -286,3 +286,19 @@ class TestJoeStyleEnergyCalibration:
             g4k = 40000 / cal(40000)
             assert g1k > 3.2
             assert g4k < 2.9
+
+    @staticmethod
+    def test_monotonic():
+        "Generate 2 cal curves: cal1 is monotonic; cal2 is not. Verify this."
+        names = ["CKAlpha", "NKAlpha", "OKAlpha", "FeLAlpha1", "NiLAlpha1", "CuLAlpha1"]
+        e = np.array([mass.STANDARD_FEATURES[n] for n in names])
+        ph1 = e * 10 / (1 + e / 2500)
+        ph2 = ph1.copy()
+        ph2[5] *= (ph2[5] / ph2[4])**(-0.85)
+        cal1 = mass.EnergyCalibration(curvetype="gain")
+        cal2 = mass.EnergyCalibration(curvetype="gain")
+        for p1, p2, n in zip(ph1, ph2, names):
+            cal1.add_cal_point(p1, n, pht_error=p1 * 1e-4)
+            cal2.add_cal_point(p2, n, pht_error=p2 * 1e-4)
+        assert cal1.ismonotonic
+        assert not cal2.ismonotonic