Repository: bluesky/bluesky
Pull request #1486 (2021-11-02 17:11:15)

ENH: implement human-readable repr for `PeakStats`
<!--- Provide a general summary of your changes in the Title above -->
Implements human-readable `__repr__` for the `PeakStats` (continuation of improvements of #1478).

## Description
<!--- Describe your changes in detail -->
This PR adds the `__repr__` and `__dict__` methods to easily see the data for the stats and derivative stats.
Used a hint from https://stackoverflow.com/a/34166604 regarding `._asdict()` for namedtuple objects.

## Motivation and Context
<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->
The previous way of reporting the PeakStats information was hard to use. Example from @gfreychet:
```py
bec._peak_stats
Out[29]: {'bd52e883-7422-4b3b-94a4-a4b5adbaaebc': {'pil1M_stats1_total': <bluesky.callbacks.fitting.PeakStats at 0x7f046f6b9850>}}
```
With this update:
```py
In [15]: bec._peak_stats
Out[15]:
{'f38cbe6a-029f-493a-a07c-73c4eb45a799': {'det': {'derivative_stats': {'cen': 0.0999999999999998,
                        'com': -0.8,
                        'crossings': array([0.1]),
                        'fwhm': None,
                        'lin_bkg': None,
                        'max': (-0.8, 0.1196183773610574),
                        'min': (1.0, -0.1196183773610574),
                        'x': array([-0.8, -0.6, -0.4, -0.2,  0. ,  0.2,  0.4,  0.6,  0.8,  1. ]),
                        'y': array([ 0.11961838,  0.10912117,  0.08784613,  0.05708233,  0.01980133,
         -0.01980133, -0.05708233, -0.08784613, -0.10912117, -0.11961838])},
   'stats': {'cen': 5.551115123125783e-17,
             'com': 0.0,
             'crossings': array([-0.65865934,  0.65865934]),
             'fwhm': 1.3173186844780238,
             'lin_bkg': None,
             'max': (0.0, 1.0),
             'min': (-1.0, 0.6065306597126334)}}}}
```

## How Has This Been Tested?
<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, and the tests you ran to -->
<!--- see how your change affects other areas of the code, etc. -->
Tested locally.

<!--
## Screenshots (if appropriate):
-->



Patch:
diff --git a/.gitignore b/.gitignore
index a3df704f..3e3d546f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -133,3 +133,5 @@ doc/source/generated/*.rst
 
 .vscode/settings.json
 .vscode/tags
+
+.hypothesis/
diff --git a/bluesky/callbacks/fitting.py b/bluesky/callbacks/fitting.py
index 9c0b305c..a509ea31 100644
--- a/bluesky/callbacks/fitting.py
+++ b/bluesky/callbacks/fitting.py
@@ -1,4 +1,5 @@
 import copy
+import pprint
 import warnings
 import numpy as np
 from collections import namedtuple
@@ -209,6 +210,9 @@ class PeakStats(CollectThenCompute):
            be the fwhm.
     """
 
+    __slots__ = ("x", "y", "x_data", "y_data", "stats", "derivative_stats",
+                 "min", "max", "com", "cen", "crossings", "fwhm", "lin_bkg")
+
     def __init__(self, x, y, *, edge_count=None,
                  calc_derivative_and_stats=False):
         self.x = x
@@ -233,11 +237,24 @@ class PeakStats(CollectThenCompute):
         super().__init__()
 
     def __getitem__(self, key):
-        if key in ["x", "y"] + list(self._stats_fields.keys()):
+        if key in ["x", "y", "stats", "derivative_stats"] + list(self._stats_fields.keys()):
             return getattr(self, key)
         else:
             raise KeyError
 
+    def __dict__(self):
+        return_dict = {}
+        if self.stats is not None:
+            return_dict["stats"] = self.stats._asdict()
+
+        if self.derivative_stats is not None:
+            return_dict["derivative_stats"] = self.derivative_stats._asdict()
+
+        return return_dict
+
+    def __repr__(self):
+        return pprint.pformat(self.__dict__())
+
     @staticmethod
     def _calc_stats(x, y, fields, edge_count=None):
         y_orig = np.copy(y)
@@ -260,7 +277,7 @@ class PeakStats(CollectThenCompute):
         fields["max"] = (x[argmax_y], y_orig[argmax_y])
         fields["com"], = np.interp(center_of_mass(y), np.arange(len(x)), x)
         mid = (np.max(y) + np.min(y)) / 2
-        crossings = np.where(np.diff((y > mid).astype(np.int)))[0]
+        crossings = np.where(np.diff((y > mid).astype(int)))[0]
         _cen_list = []
         for cr in crossings.ravel():
             _x = x[cr:cr + 2]


Test patch:
diff --git a/bluesky/tests/test_bec.py b/bluesky/tests/test_bec.py
index 3a093a6c..e6e16f10 100644
--- a/bluesky/tests/test_bec.py
+++ b/bluesky/tests/test_bec.py
@@ -3,6 +3,7 @@ import pytest
 import jsonschema
 import time as ttime
 from datetime import datetime
+import numpy as np
 from bluesky.plans import scan, grid_scan
 import bluesky.preprocessors as bpp
 import bluesky.plan_stubs as bps
@@ -259,8 +260,38 @@ def test_bec_peak_stats_derivative_and_stats(RE, hw):
 
     desc_uid = c.descriptor[uid][0]["uid"]
     ps = bec._peak_stats[desc_uid]["det_a"]
+
     assert hasattr(ps, "derivative_stats")
 
-    der_fields = ["x", "y", "min", "max", "com", "cen", "fwhm", "crossings"]
+    fields = ["min", "max", "com", "cen", "fwhm", "crossings"]
+    der_fields = ["x", "y"] + fields
     for field in der_fields:
         assert hasattr(ps.derivative_stats, field), f"{field} is not an attribute of ps.der"
+
+    assert isinstance(ps.__repr__(), str)
+
+    # These imports are needed by the `eval` below:
+    from numpy import array  # noqa F401
+    from collections import OrderedDict  # noqa F401
+
+    out = eval(str(ps))
+    assert isinstance(out, dict)
+    for key in ("stats", "derivative_stats"):
+        assert key in out
+
+    for field in fields:
+        stats_value = getattr(ps.stats, field)
+        out_value = out["stats"][field]
+        if stats_value is not None:
+            assert np.allclose(stats_value, out_value)
+        else:
+            stats_value == out_value
+
+    for field in der_fields:
+        stats_value = getattr(ps.derivative_stats, field)
+        out_value = out["derivative_stats"][field]
+        if stats_value is not None:
+            assert np.allclose(stats_value,
+                               out_value)
+        else:
+            stats_value == out_value