Repository: pcdshub/engineering_tools
Pull request #273: Add: pointing_stability scripts
Author: login: aberges-SLAC
Created: 2025-04-25T22:40:56Z | Merged: 2025-05-01T21:46:16Z

<!--- Provide a general summary of your changes in the Title above -->
## Description
Some laser scientists have been wanting a general solution for tracking the changes in beam pointing in their diagnostic paths in the TILEs. Matt Bain ended up writing the specific solution for one of his cameras then passed it to me to generalize for qRIXS. I ended up generalizing this for use across MODS in general and realized if I did it for the Baslers, it was somewhat trivial to do this for the rest of the gigecam models.

I'll add some release documents and change the readme if this is an appropriate repo to have it in.

## Motivation and Context
With the NEH delivering multiple laser sources to different interaction points that face different challenges (i.e. mode changes in TMO IP1 at high rep. rate, pointing changes when HVAC changes in the laser hall, etc.), it is important to have a metric for tracking & troubleshooting the issues the operators face. This dumps the data for the scientists to work up in later analysis, but also provides images/plots to add to the `elog` when posting issues during shifts.

Since the use of near and far-field imaging is ubiquitous in laser areas, this script and brief analysis has use pretty much everywhere we intend to use a beam.   

## How Has This Been Tested?
I've ran these on several cameras in TMO IP1, chemRIXS, and the Laser Hall while tweaking the scripts and visualization. Granted, I have _only_ tested this on Basler cameras. I did make sure the `PIXEL_DICT` was using the names of the camera model returned by `$CAM:ModelRBV`.

## Where Has This Been Documented?
The code and this PR!

```
usage: pointing_stability [-h] [-l FOCAL_LENGTH] [-d DIR] [-f FILENAME] [-p PIXEL_SIZE] [-q] [-r] [-s SIZE] cam

Collect centroids from the camera then determine the positional stability using ISO 11145:2018. Default is to use far-field calculations and a 10 cm focal length.

positional arguments:
  cam                   PV prefix for the camera using ECS naming convention

optional arguments:
  -h, --help            show this help message and exit
  -l FOCAL_LENGTH, --focal_length FOCAL_LENGTH
                        Focal lenght of the imaging lens in cm as a float. Defaults to 10 cm.
  -d DIR, --dir DIR     Set directory for the output data. Defaults to $PWD
  -f FILENAME, --filename FILENAME
                        Set the base filename for the output data.
  -p PIXEL_SIZE, --pixel_size PIXEL_SIZE
                        Set the pixel size if you have a special snowflake camera.
  -q, --quiet           Supress the progressbar and graphs from printing to terminal
  -r, --near_field      Whether to perform calculations assuming near field imaging. 
  -s SIZE, --size SIZE  Total number of centroids to acquire before calculating the pointing stability. Defaults to 5000 counts

For more information on subcommands, use: pointing_stability --help
```

## Screenshots (if appropriate):
![image](https://github.com/user-attachments/assets/cbddbfe7-7d91-4daf-ae19-17e8add6e3c2)
![image](https://github.com/user-attachments/assets/34493ee0-3390-4cfc-9922-369057837e9e)
![data_lm1k4_inj_dp1_tf1_ff1_D2025_04_25_T15_39_25](https://github.com/user-attachments/assets/000832b6-4fc4-4c7a-9ac8-c91de5d6fcff)




Diff:
diff --git a/scripts/pointing_stability b/scripts/pointing_stability
new file mode 100755
index 00000000..68099748
--- /dev/null
+++ b/scripts/pointing_stability
@@ -0,0 +1,6 @@
+#!/usr/bin/bash
+
+# execute python script
+THIS_DIR="$(dirname "$(realpath "${BASH_SOURCE[0]}")")"
+
+/cds/group/pcds/pyps/conda/py39/envs/pcds-5.9.1/bin/python "${THIS_DIR}/pointing_stability.py" "$@"
diff --git a/scripts/pointing_stability.py b/scripts/pointing_stability.py
new file mode 100644
index 00000000..7d100c34
--- /dev/null
+++ b/scripts/pointing_stability.py
@@ -0,0 +1,445 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import time
+from functools import partial
+from typing import Optional
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV, caget
+from matplotlib.figure import Figure
+from numpy.typing import ArrayLike, NDArray
+
+CAMERA_PIXEL_DICT = {'Manta_G046B': 8.3,
+                     'Manta_G146B': 4.65,
+                     'Manta_G146C': 4.65,
+                     'Manta_G2460B': 2.74,
+                     'acA1300-22gm': 3.75,
+                     'acA1300-30gm': 3.75,
+                     'acA1300-60gmNIR': 5.3,
+                     'acA1440-73gm': 3.45,
+                     'acA1600-60gm': 4.5,
+                     'acA2500-20gm': 4.8,
+                     'FLIR_A65': 17.0,
+                     'FLIR_AX5': 50.0,
+                     'JAI_AM-201GE': 5.5,
+                     'PyrocamIIIHR': 80.0,
+                     'Mako G-030B': 7.4,
+                     'Mako G-032B': 7.4,
+                     'Mako G-040B': 6.9,
+                     'Mako G-125B': 3.75,
+                     'Mako G-125C': 3.75,
+                     'Mako G-158B': 3.45,
+                     'Mako G-192B': 4.5,
+                     'Mako G-234B': 5.86,
+                     'Mako G-319B': 3.45,
+                     'Mako G-319C': 3.45,
+                     'Mako G-419B': 5.5,
+                     'Manta G-033B': 9.9,
+                     'Manta_G-046B': 8.3,
+                     'Manta_G-125B': 3.75,
+                     'Manta_G-145B': 6.45,
+                     'Manta_G-145C': 6.45,
+                     'Manta_G-146B': 4.65,
+                     'Manta_G-146C': 4.65,
+                     'Manta_G-158B': 3.45,
+                     'Manta_G-201B': 4.4,
+                     'Manta G-2460B': 2.74,
+                     'Manta_G-282B': 3.69,
+                     'Manta G-419B': 5.5,
+                     'Manta G-419B-NIR': 5.5,
+                     'Manta_G-419C': 5.5,
+                     'Manta_G-504B': 3.45,
+                     'Manta_G-504C': 3.45,
+                     'Manta_G-917B': 3.69,
+                     'Manta G-917C': 3.69,
+                     'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal length of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD.')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.'
+                        ' Defaults to "data_" appended by the camera name and timestamp.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-n', '--near_field', action='store_true',
+                        default=False,
+                        help='Whether to perform calculations assuming near field imaging.')
+    parser.add_argument('-s', '--size', type=int,
+                        default=5000,
+                        help='Total number of centroids to acquire before calculating'
+                        ' the pointing stability. Defaults to 5000 counts')
+    return parser
+
+
+def build_data(data: list, value, **kw):
+    """Simple callback for appending camonitor output to a list"""
+    data.append(value)
+
+
+def print_progress_bar(iteration: int, total: int,
+                       prefix: str = '', suffix: str = '', decimals: int = 1,
+                       length: int = 100, fill: str = '\u2588',
+                       end: str = "\r") -> None:
+    """
+    Print the progressbar to terminal
+    Parameters
+    -----------
+    iteration (int):
+        iterator
+    total (int):
+        total number of iterations
+    prefix (str, optional):
+        Prefix to progress bar. Defaults to ''.
+    suffix (str, optional):
+        Suffix to progress bar. Defaults to ''.
+    decimals (int, optional):
+        Number of decimals to display in percentage. Defaults to 1.
+    length (int, optional):
+        Length of the progressbar. Defaults to 100.
+    fill (str, optional):
+        Progressbar fill symbol. Defaults to '\u2588' █.
+    end (str, optional):
+        Progressbar terminator. Defaults to "\r".
+    """
+
+    percent = ('{0:.' + f'{decimals}' + 'f}').format(100 * (iteration / float(total)))
+    filledLength = int(length * iteration // total)
+    bar = fill * filledLength + '-' * (length - filledLength)
+    color = Fore.LIGHTYELLOW_EX if round(filledLength / length, 1) < 1 else Fore.GREEN
+
+    print(color + f'\r{prefix} |{bar}| {percent}% {suffix}' + Style.RESET_ALL, end=end)
+
+
+def connect_centroids(cam_prefix: str) -> list[PV]:
+    """
+    Connect to the centroid PVs and wait for the PV objects to connect.
+    Parameters
+    -----------
+    cam_prefix (str):
+        Camera PV prefix.
+    Returns
+    -----------
+    list[epics.PV]:
+        list of the PV objects
+    """
+    centroid_x = PV(f'{cam_prefix}Stats2:CentroidX_RBV', connection_timeout=5)
+    centroid_y = PV(f'{cam_prefix}Stats2:CentroidY_RBV', connection_timeout=5)
+
+    centroid_x.wait_for_connection(timeout=5)
+    centroid_y.wait_for_connection(timeout=5)
+
+    return centroid_x, centroid_y
+
+
+def convert_px_to_um(data: ArrayLike, pixel_size: float, bin: int) -> NDArray:
+    """
+    Convert list of centroid data from pixels to micron.
+    Returns NDArray.
+    """
+    return np.asarray(data)*pixel_size*bin
+
+
+def center_dataset(data: NDArray) -> NDArray:
+    """
+    Convert dataset to displacement from the mean.
+    Returns NDArray.
+    """
+    mean = np.mean(data)
+    return data - mean
+
+
+def calc_pos_stability(centered_data: NDArray) -> float:
+    """
+    Calculate the positional stability using statistics in the centered array data.
+    Returns float.
+    """
+    return 4*np.std(centered_data, ddof=1)
+
+
+def calc_angular_displacement(centered_data: NDArray,
+                              focal_length: float) -> list[NDArray, float, float]:
+    """
+    For far field calculations, convert the positional displacements to angular ones.
+    Parameters
+    -----------
+    centered_data (NDArray): centroid data represented as displacement from the mean
+    focal_length (float): focal length of the lens in micron.
+
+    Returns
+    --------
+    list[NDArray, float, float]: theta, theta_avg, theta_std
+        theta is an NDArray in microradians
+        theta_avg is the mean of the theta array
+        theta_std is the standard deviation (N-1)
+    """
+    theta = np.arctan(centered_data/focal_length)*1E6
+    theta_avg = np.mean(theta)
+    theta_std = np.std(theta, ddof=1)
+    return [theta, theta_avg, theta_std]
+
+
+def make_figures(x_positions: NDArray,
+                 y_positions: NDArray,
+                 pos_stabilities: list[float],
+                 camera: str, num_plots: int = 3, quiet: bool = False,
+                 theta_x: Optional[NDArray] = None,
+                 theta_y: Optional[NDArray] = None,
+                 theta_x_std: Optional[float] = None,
+                 theta_y_std: Optional[float] = None
+                 ) -> Figure:
+    """
+    Generate the figures for the pointing stability output. Optionally
+    include the angular data. Returns a matplotlib Figure.
+
+    Parameters
+    -----------
+    x_positions (NDArray):
+        Positions of mean-centered centroids in X
+    y_positions (NDArray):
+        Positions of mean-centered centroids in Y
+    pos_stabilities (list[float]):
+        List of positional stabilities. e.g. [x_stability, y_stability]
+    camera (str):
+        Camera PV prefix
+    num_plots (int, optional):
+        Number of plots to use. Defaults to 3.
+    quiet (bool, optional):
+        Surpress printing the graphic. Defaults to False.
+    theta_x (NDArray, optional):
+        Angular data in X. Defaults to None.
+    theta_y (NDArray, optional):
+        Angular data in Y. Defaults to None.
+    theta_x_std (float, optional):
+        Standard deviation in theta_x
+    theta_y_std (float, optional):
+        Standard deviation in theta_y
+
+    Returns
+    --------
+        Figure: matplotlib Figure
+    """
+    if any([_item is None for _item in [theta_x, theta_y]]):
+        num_plots = 2
+    fig, axs = plt.subplots(num_plots,
+                            height_ratios=(num_plots-1)*[1]+[0.5])
+    # Positional displacement plot
+    _positions = np.asarray([x_positions, y_positions])
+    _min = _positions.min()*1.05
+    _max = _positions.max()*1.05
+    axs[0].scatter(x=x_positions, y=y_positions)
+    axs[0].axhline(y=0, color='black', linestyle='--')
+    axs[0].axvline(x=0, color='black', linestyle='--')
+    axs[0].set_xlim(_min, _max)
+    axs[0].set_ylim(_min, _max)
+    axs[0].set_xlabel('X displacement (\u03bcm)')
+    axs[0].set_ylabel('Y displacement (\u03bcm)')
+    axs[0].set_box_aspect(1)
+    # Stat data for table printing
+    col_labels = ['X', 'Y']
+    row_labels = ['\u0394 (\u03bcm)']
+    table_data = [pos_stabilities]
+    # Angular displacement subplot
+    if not any([_item is None for _item in [theta_x, theta_y]]):
+        axs[1].hist(theta_x, bins='fd',
+                    fc=(0, 0, 1, 0.5),
+                    edgecolor='black',
+                    label='X')
+        axs[1].hist(theta_y, bins='fd',
+                    fc=(1, 0.25, 0.25, 0.5),
+                    edgecolor='black',
+                    label='Y')
+        axs[1].legend(loc='upper right')
+        axs[1].set_xlabel("Pointing (\u03bcrad)")
+        axs[1].set_ylabel("Counts")
+        axs[1].set_box_aspect(1)
+        row_labels = row_labels + ['\u03b8 std_dev (\u03bcrad)']
+        table_data = table_data + [[theta_x_std, theta_y_std]]
+    # Now add the table
+    table_data = [[f'{item:2.3e}' for item in row] for row in table_data]
+    axs[-1].table(cellText=table_data,
+                  cellLoc='center',
+                  loc='center',
+                  rowLabels=row_labels,
+                  colLabels=col_labels
+                  )
+    axs[-1].axis('Off')
+    plt.suptitle(f'{camera}')
+    plt.subplots_adjust(hspace=0.4)
+    fig.set_figheight(10)
+    fig.set_figwidth(10)
+    plt.tight_layout()
+
+    return fig
+
+
+def main():
+    # Build parser and initialize lists
+    parser = build_parser()
+    args = parser.parse_args()
+
+    # Make sure the prefix matches AD standard
+    camera = args.cam + ':' if args.cam[-1] != ':' else args.cam
+    model = caget(f'{camera}Model_RBV', connection_timeout=5)
+    if not model:
+        raise Exception(f'Could not connect to {camera}Model_RBV.')
+    # Make sure you're capturing if the bin is != 1
+    # First check the source of the stats plugin
+    stats_input_port = caget(f'{camera}Stats2:NDArrayPort', connection_timeout=5)
+    port = camera if 'CAM' in stats_input_port else f'{camera}{stats_input_port}:'
+    bin_x = caget(f'{port}BinX_RBV', connection_timeout=5)
+    bin_y = caget(f'{port}BinY_RBV', connection_timeout=5)
+    if not bin_x or not bin_y:
+        raise Exception('Could not connect to PVs:\n'
+                        '\n'.join([f'{port}{_suffix}'
+                                   for _suffix in ['BinX_RBV', 'BinY_RBV']])
+                        )
+    # Initialize lists and connect to centroid PVs
+    total = args.size
+    x_samples = []
+    y_samples = []
+
+    centroid_x_pv, centroid_y_pv = connect_centroids(camera)
+
+    # Collect data
+    centroid_x_pv.add_callback(partial(build_data, x_samples))
+    centroid_y_pv.add_callback(partial(build_data, y_samples))
+
+    while min(len(x_samples), len(y_samples)) < total:
+        if not args.quiet:
+            print_progress_bar(min(len(x_samples), len(y_samples)) + 1,
+                               total=total,
+                               prefix='Collecting data...', fill="\u2588")
+        time.sleep(0.1)
+
+    # Remove callback after collection
+    centroid_x_pv.remove_callback(1)
+    centroid_y_pv.remove_callback(1)
+
+    # Convert focal length to micron
+    focal_length = args.focal_length*1E4
+
+    # Trim to same array lengths if necessary
+    if len(x_samples) != len(y_samples):
+        _length = min(len(x_samples), len(y_samples))
+        x_samples = x_samples[:_length]
+        y_samples = y_samples[:_length]
+
+    # Convert from pixels to real dimensions using vendor's data and account for binning
+    if args.pixel_size:
+        pixel_size = args.pixel_size
+    else:
+        pixel_size = CAMERA_PIXEL_DICT[model]
+    x_samples_um = convert_px_to_um(x_samples, pixel_size, bin_x)
+    y_samples_um = convert_px_to_um(y_samples, pixel_size, bin_y)
+
+    # Now let's do some fun math
+    x_centered = center_dataset(x_samples_um)
+    y_centered = center_dataset(y_samples_um)
+
+    pos_stabilities = [calc_pos_stability(x_centered), calc_pos_stability(y_centered)]
+
+    # Now let's look at angular displacements
+    if not args.near_field:
+        theta_x, theta_x_avg, theta_x_std = calc_angular_displacement(x_centered, focal_length)
+        theta_y, theta_y_avg, theta_y_std = calc_angular_displacement(y_centered, focal_length)
+
+        theta_means = [theta_x_avg, theta_y_avg]
+        theta_stds = [theta_x_std, theta_y_std]
+
+    if not args.quiet:
+        print('\nPositional stability \u0394 :\n\t'
+              + '('
+              + ', '.join([f'{_s:2.3e}'
+                          for _s in pos_stabilities]
+                          )
+              + ') \u03bcm')
+
+    if not args.near_field and not args.quiet:
+        # Print (Θ_x ± σ_x, Θ_y ± σ_y)
+        print('Angular pointing:\n\t'
+              + '('
+              + ', '.join([f'{_m:2.3f} \u00b1 {_std:2.3e}'
+                          for _m, _std in zip(theta_means, theta_stds)]
+                          )
+              + ') \u03bcrad')
+    # #-----------------------------------------------------------------------------------------# #
+    # Plotting outputs
+    # #-----------------------------------------------------------------------------------------# #
+    if not args.near_field:
+        fig = make_figures(x_positions=x_centered,
+                           y_positions=y_centered,
+                           pos_stabilities=pos_stabilities,
+                           theta_x=theta_x,
+                           theta_y=theta_y,
+                           theta_x_std=theta_x_std,
+                           theta_y_std=theta_y_std,
+                           camera=camera,
+                           quiet=args.quiet)
+    else:
+        fig = make_figures(x_positions=x_centered,
+                           y_positions=y_centered,
+                           pos_stabilities=pos_stabilities,
+                           camera=camera,
+                           quiet=args.quiet)
+
+    # Format the data for saving
+
+    camera_header = (f"{camera.replace(':', '_').lower()}_imaging_distance_{focal_length*1E-4}"
+                     + '\nx_centered (\u03bcm),y_centered (\u03bcm)')
+    data = np.concatenate((x_centered, y_centered))
+    data = np.resize(data, (2, total))
+
+    timestamp = time.strftime('D%Y_%m_%d_T%H_%M_%S')
+
+    _dir = os.getcwd()
+    # Use the user-specified directory and create if it does not exist
+    if args.dir:
+        _dir = args.dir
+        if not os.path.exists(_dir):
+            os.makedirs(_dir)
+
+    # Set a default file name and use the user-specified one if it supplied
+    file_dest = f"{_dir}/data_{camera.replace(':', '_').lower()}{timestamp}"
+    if args.filename:
+        file_dest = f"{_dir}/{args.filename}"
+
+    np.savetxt(f"{file_dest}.csv", np.transpose(data), delimiter=',', header=camera_header)
+    fig.savefig(f"{file_dest}.png")
+
+    if not args.quiet:
+        plt.show()
+
+
+if __name__ == '__main__':
+    main()


Reviews:
user: login: KaushikMalapati
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T03:52:46Z
user: login: KaushikMalapati
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T03:53:44Z
user: login: KaushikMalapati
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T03:55:52Z
user: login: KaushikMalapati
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T03:56:53Z
user: login: KaushikMalapati
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T03:58:07Z
user: login: KaushikMalapati
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T04:10:59Z
user: login: KaushikMalapati
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T04:12:57Z
user: login: KaushikMalapati
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T04:14:23Z
user: login: KaushikMalapati
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T04:15:45Z
user: login: KaushikMalapati
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T04:34:37Z
user: login: tangkong
body: Just a small additional comment.  I still think this needs to be re-based on master, not on the grep-more-ioc branch
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T16:45:19Z
user: login: aberges-SLAC
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T18:38:03Z
user: login: aberges-SLAC
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T18:38:43Z
user: login: aberges-SLAC
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T18:39:56Z
user: login: aberges-SLAC
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T18:40:58Z
user: login: aberges-SLAC
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T18:42:03Z
user: login: KaushikMalapati
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T18:47:03Z
user: login: aberges-SLAC
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T18:57:02Z
user: login: tangkong
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T20:24:23Z
user: login: aberges-SLAC
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T21:20:41Z
user: login: aberges-SLAC
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T21:22:20Z
user: login: aberges-SLAC
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T23:37:46Z
user: login: tangkong
body: Only holding off on approval until Kaushik's timeout comment is addressed, otherwise I don't see too much reason to hold this up.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T17:17:22Z
user: login: aberges-SLAC
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T17:46:53Z
user: login: aberges-SLAC
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T17:55:32Z
user: login: tangkong
body: Thanks for humoring my nitpicks!
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T20:46:18Z
user: login: aberges-SLAC
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T20:54:11Z
user: login: aberges-SLAC
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T20:54:50Z
user: login: tangkong
body: I left so many even I couldn't remember all of them
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T21:04:05Z
user: login: tangkong
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T21:08:58Z

Review comments:
diff_hunk: @@ -0,0 +1,344 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import sys
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal lenght of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-r', '--near_field', action='store_true',
+                        default=False,
+                        help='Whether to perform calculations assuming near field imaging.')
+    parser.add_argument('-s', '--size', type=int,
+                        default=5000,
+                        help='Total number of centroids to acquire before calculating'
+                        ' the pointing stability. Defaults to 5000 counts')
+    return parser
+
+
+def build_data(data: list, value, **kw):
+    """Simple callback for appending camonitor output to a list"""
+    data.append(value)
+
+
+def print_progress_bar(iteration: int, total: int,
path: scripts/pointing_stability.py
user: login: KaushikMalapati
body: You might be able to just use tdqm to accomplish this
https://tqdm.github.io/
created_at: 2025-04-26T03:52:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,344 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import sys
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal lenght of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-r', '--near_field', action='store_true',
+                        default=False,
+                        help='Whether to perform calculations assuming near field imaging.')
+    parser.add_argument('-s', '--size', type=int,
+                        default=5000,
+                        help='Total number of centroids to acquire before calculating'
+                        ' the pointing stability. Defaults to 5000 counts')
+    return parser
+
+
+def build_data(data: list, value, **kw):
+    """Simple callback for appending camonitor output to a list"""
+    data.append(value)
+
+
+def print_progress_bar(iteration: int, total: int,
+                       prefix: str = '', suffix: str = '', decimals: int = 1,
+                       length: int = 100, fill: str = '\u2588',
+                       end: str = "\r") -> None:
+    """
+    Print the progressbar to terminal
+    Parameters
+    -----------
+    iteration (int):
+        iterator
+    total (int):
+        total number of iterations
+    prefix (str, optional):
+        Prefix to progress bar. Defaults to ''.
+    suffix (str, optional):
+        Suffix to progress bar. Defaults to ''.
+    decimals (int, optional):
+        Number of decimals to display in percentage. Defaults to 1.
+    length (int, optional):
+        Length of the progressbar. Defaults to 100.
+    fill (str, optional):
+        Progressbar fill symbol. Defaults to '\u2588' █.
+    end (str, optional):
+        Progressbar terminator. Defaults to "\r".
+    """
+
+    percent = ('{0:.' + f'{decimals}' + 'f}').format(100 * (iteration / float(total)))
+    filledLength = int(length * iteration // total)
+    bar = fill * filledLength + '-' * (length - filledLength)
+    color = Fore.LIGHTYELLOW_EX if round(filledLength / length, 1) < 1 else Fore.GREEN
+
+    print(color + f'\r{prefix} |{bar}| {percent}% {suffix}' + Style.RESET_ALL, end=end)
+
+
+def connect_centroids(cam_prefix: str) -> list[PV]:
+    """
+    Connect to the centroid PVs and wait for the PV objects to connect.
+    Parameters
+    -----------
+    cam_prefix (str):
+        Camera PV prefix.
+    Returns
+    -----------
+    list[epics.PV]:
+        list of the PV objects
+    """
+    centroid_x = PV(f'{cam_prefix}Stats2:CentroidX_RBV', connection_timeout=5)
+    centroid_y = PV(f'{cam_prefix}Stats2:CentroidY_RBV', connection_timeout=5)
+
+    centroid_x.wait_for_connection()
+    centroid_y.wait_for_connection()
+
+    return centroid_x, centroid_y
+
+
+def main():
+    # Build parser and initialize lists
+    parser = build_parser()
+    args = parser.parse_args()
+
+    # Make sure the prefix matches AD standard
+    camera = args.cam + ':' if args.cam[-1] != ':' else args.cam
+    model = PV(f'{camera}Model_RBV', connection_timeout=5).get()
+    if not model:
+        raise Exception(f'Could not connect to {camera}Model_RBV.')
+    # Make sure you're capturing if the bin is != 1
+    # First check the source of the stats plugin
+    stats_input_port = PV(f'{camera}Stats2:NDArrayPort', connection_timeout=5).get()
+    port = camera if 'CAM' in stats_input_port else f'{camera}{stats_input_port}:'
+    bin_x = PV(f'{port}BinX_RBV', connection_timeout=5).get()
+    bin_y = PV(f'{port}BinX_RBV', connection_timeout=5).get()
+    # Initialize lists and connect to centroid PVs
+    total = 5000
+    if args.size:
+        total = args.size
+    x_samples = []
+    y_samples = []
+
+    centroid_x_pv, centroid_y_pv = connect_centroids(camera)
+
+    # Collect data
+    centroid_x_pv.add_callback(partial(build_data, x_samples))
+    centroid_y_pv.add_callback(partial(build_data, y_samples))
+
+    while min(len(x_samples), len(y_samples)) < total:
+        if not args.quiet:
+            print_progress_bar(min(len(x_samples), len(y_samples)) + 1,
+                               total=total,
+                               prefix='Collecting data...', fill="\u2588")
+        time.sleep(0.1)
+
+    # Remove callback after collection
+    centroid_x_pv.remove_callback(1)
+    centroid_y_pv.remove_callback(1)
+
+    # Convert focal length to micron
+    focal_length = 10.0*1E4
+    if args.focal_length:
+        focal_length = args.focal_length*1E4
+
+    # Trim to same array lengths if necessary
+    if len(x_samples) != len(y_samples):
+        _length = min(len(x_samples), len(y_samples))
+        x_samples = x_samples[:_length]
+        y_samples = y_samples[:_length]
+
+    # Convert from pixels to real dimensions using vendor's data and account for binning
+    if args.pixel_size:
+        pixel_size = args.pixel_size
+    else:
+        pixel_size = PIXEL_DICT[model]
+    x_samples_um = np.asarray(x_samples)*pixel_size*bin_x
+    y_samples_um = np.asarray(y_samples)*pixel_size*bin_y
+
+    # Now let's do some fun math
+    x_mean = np.mean(x_samples_um)
+    y_mean = np.mean(y_samples_um)
+    x_centered = x_samples_um - x_mean
+    y_centered = y_samples_um - y_mean
+
+    x_std_dev = np.std(x_samples_um, ddof=1)
+    y_std_dev = np.std(y_samples_um, ddof=1)
+
+    positional_stability_x = 4*x_std_dev
+    positional_stability_y = 4*y_std_dev
+    pos_stabilities = [positional_stability_x, positional_stability_y]
+
+    # Now let's look at angular displacements
+    if not args.near_field:
+        theta_x = np.arctan(x_centered/focal_length)*1E6
+        theta_y = np.arctan(y_centered/focal_length)*1E6
+
+        theta_x_avg = np.mean(theta_x)
+        theta_y_avg = np.mean(theta_y)
+
+        theta_x_std = np.std(theta_x, ddof=1)
+        theta_y_std = np.std(theta_y, ddof=1)
+
+        theta_means = [theta_x_avg, theta_y_avg]
+        theta_stds = [theta_x_std, theta_y_std]
+
+    if not args.quiet:
+        print('\nPositional stability \u0394 :\n\t'
+              + '('
+              + ', '.join([f'{_s:2.3e}'
+                          for _s in pos_stabilities]
+                          )
+              + ') \u03bcm')
+
+    if not args.near_field and not args.quiet:
+        # Print (Θ_x ± σ_x, Θ_y ± σ_y)
+        print('Angular pointing:\n\t'
+              + '('
+              + ', '.join([f'{_m:2.3e} \u00b1 {_std:2.3e}'
+                          for _m, _std in zip(theta_means, theta_stds)]
+                          )
+              + ') \u03bcrad')
+    # #-----------------------------------------------------------------------------------------# #
+    # Plotting outputs
+    # #-----------------------------------------------------------------------------------------# #
+    # Positional displacement subplot
+    num_plots = 3
+    if args.near_field:
+        num_plots = 2
+    fig, axs = plt.subplots(num_plots,
+                            height_ratios=(num_plots-1)*[1]+[0.5])
+    axs[0].scatter(x_centered, y_centered)
+    axs[0].axhline(y=0, color='black', linestyle='--')
+    axs[0].axvline(x=0, color='black', linestyle='--')
+    axs[0].set_xlabel('X displacement (\u03bcm)')
+    axs[0].set_ylabel('Y displacement (\u03bcm)')
+    axs[0].set_box_aspect(1)
+    # Stat data for table printing
+    col_labels = ['X', 'Y']
+    row_labels = ['\u0394 (\u03bcm)']
+    table_data = [pos_stabilities]
+    # Angular displacement subplot
+    if not args.near_field:
+        axs[1].hist(theta_x, bins='fd',
+                    fc=(0, 0, 1, 0.5),
+                    edgecolor='black',
+                    label='X')
+        axs[1].hist(theta_y, bins='fd',
+                    fc=(1, 0.25, 0.25, 0.5),
+                    edgecolor='black',
+                    label='Y')
+        axs[1].legend(loc='upper right')
+        axs[1].set_xlabel("Pointing (\u03bcrad)")
+        axs[1].set_ylabel("Counts")
+        axs[1].set_box_aspect(1)
+        row_labels = row_labels + ['\u03b8_avg (\u03bcrad)', '\u03b8 std_dev (\u03bcrad)']
+        table_data = table_data + [theta_means, theta_stds]
+    # Now add the table
+    table_data = [[f'{_s:2.3e}' for _s in _ls] for _ls in table_data]
+    axs[-1].table(cellText=table_data,
+                  cellLoc='center',
+                  loc='center',
+                  rowLabels=row_labels,
+                  colLabels=col_labels
+                  )
+    axs[-1].axis('Off')
+    plt.suptitle(f'{camera}')
+    plt.subplots_adjust(hspace=0.4)
+    fig.set_figheight(10)
+    fig.set_figwidth(10)
+    plt.tight_layout()
+    if not args.quiet:
+        plt.show()
+
+    # Format the data for saving
+
+    camera_header = (f"{camera.replace(':', '_').lower()}_imaging_distance_{focal_length*1E-4}"
+                     + '\nx_centered (\u03bcm),y_centered (\u03bcm)')
+    data = np.concatenate((x_centered, y_centered))
+    data = np.resize(data, (2, total))
+
+    timestamp = time.strftime('D%Y_%m_%d_T%H_%M_%S')
+
+    _dir = os.getcwd()
+    # Use the user-specified directory and create if it does not exist
+    if args.dir:
+        _dir = args.dir
+        if not os.path.exists(_dir):
+            os.makedirs(_dir)
+
+    # Set a default file name and use the user-specified one if it supplied
+    file_dest = f"{_dir}/data_{camera.replace(':', '_').lower()}{timestamp}"
+    if args.filename:
+        file_dest = f"{_dir}/{args.filename}"
+
+    np.savetxt(f"{file_dest}.csv", np.transpose(data), delimiter=',', header=camera_header)
+    fig.savefig(f"{file_dest}.png")
+
+    sys.exit(0)
path: scripts/pointing_stability.py
user: login: KaushikMalapati
body: Is is necessary to call this explicitly?
created_at: 2025-04-26T03:53:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,344 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import sys
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal lenght of the imaging lens in cm as a float. Defaults to 10 cm.')
path: scripts/pointing_stability.py
user: login: KaushikMalapati
body: ```suggestion
                        help='Focal length of the imaging lens in cm as a float. Defaults to 10 cm.')
```
created_at: 2025-04-26T03:55:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,344 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import sys
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal lenght of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-r', '--near_field', action='store_true',
path: scripts/pointing_stability.py
user: login: KaushikMalapati
body: Why isn't this `-n`?
created_at: 2025-04-26T03:56:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,344 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import sys
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal lenght of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.')
path: scripts/pointing_stability.py
user: login: KaushikMalapati
body: You should mention what the default filename is like you did for the dir
created_at: 2025-04-26T03:58:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,344 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import sys
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal lenght of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-r', '--near_field', action='store_true',
+                        default=False,
+                        help='Whether to perform calculations assuming near field imaging.')
+    parser.add_argument('-s', '--size', type=int,
+                        default=5000,
+                        help='Total number of centroids to acquire before calculating'
+                        ' the pointing stability. Defaults to 5000 counts')
+    return parser
+
+
+def build_data(data: list, value, **kw):
+    """Simple callback for appending camonitor output to a list"""
+    data.append(value)
+
+
+def print_progress_bar(iteration: int, total: int,
+                       prefix: str = '', suffix: str = '', decimals: int = 1,
+                       length: int = 100, fill: str = '\u2588',
+                       end: str = "\r") -> None:
+    """
+    Print the progressbar to terminal
+    Parameters
+    -----------
+    iteration (int):
+        iterator
+    total (int):
+        total number of iterations
+    prefix (str, optional):
+        Prefix to progress bar. Defaults to ''.
+    suffix (str, optional):
+        Suffix to progress bar. Defaults to ''.
+    decimals (int, optional):
+        Number of decimals to display in percentage. Defaults to 1.
+    length (int, optional):
+        Length of the progressbar. Defaults to 100.
+    fill (str, optional):
+        Progressbar fill symbol. Defaults to '\u2588' █.
+    end (str, optional):
+        Progressbar terminator. Defaults to "\r".
+    """
+
+    percent = ('{0:.' + f'{decimals}' + 'f}').format(100 * (iteration / float(total)))
+    filledLength = int(length * iteration // total)
+    bar = fill * filledLength + '-' * (length - filledLength)
+    color = Fore.LIGHTYELLOW_EX if round(filledLength / length, 1) < 1 else Fore.GREEN
+
+    print(color + f'\r{prefix} |{bar}| {percent}% {suffix}' + Style.RESET_ALL, end=end)
+
+
+def connect_centroids(cam_prefix: str) -> list[PV]:
+    """
+    Connect to the centroid PVs and wait for the PV objects to connect.
+    Parameters
+    -----------
+    cam_prefix (str):
+        Camera PV prefix.
+    Returns
+    -----------
+    list[epics.PV]:
+        list of the PV objects
+    """
+    centroid_x = PV(f'{cam_prefix}Stats2:CentroidX_RBV', connection_timeout=5)
+    centroid_y = PV(f'{cam_prefix}Stats2:CentroidY_RBV', connection_timeout=5)
+
+    centroid_x.wait_for_connection()
+    centroid_y.wait_for_connection()
+
+    return centroid_x, centroid_y
+
+
+def main():
+    # Build parser and initialize lists
+    parser = build_parser()
+    args = parser.parse_args()
+
+    # Make sure the prefix matches AD standard
+    camera = args.cam + ':' if args.cam[-1] != ':' else args.cam
+    model = PV(f'{camera}Model_RBV', connection_timeout=5).get()
path: scripts/pointing_stability.py
user: login: KaushikMalapati
body: If you just want to get the value once, it makes more sense to just use caget
https://pyepics.github.io/pyepics/overview.html#caget
created_at: 2025-04-26T04:10:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,344 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import sys
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal lenght of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-r', '--near_field', action='store_true',
+                        default=False,
+                        help='Whether to perform calculations assuming near field imaging.')
+    parser.add_argument('-s', '--size', type=int,
+                        default=5000,
+                        help='Total number of centroids to acquire before calculating'
+                        ' the pointing stability. Defaults to 5000 counts')
+    return parser
+
+
+def build_data(data: list, value, **kw):
+    """Simple callback for appending camonitor output to a list"""
+    data.append(value)
+
+
+def print_progress_bar(iteration: int, total: int,
+                       prefix: str = '', suffix: str = '', decimals: int = 1,
+                       length: int = 100, fill: str = '\u2588',
+                       end: str = "\r") -> None:
+    """
+    Print the progressbar to terminal
+    Parameters
+    -----------
+    iteration (int):
+        iterator
+    total (int):
+        total number of iterations
+    prefix (str, optional):
+        Prefix to progress bar. Defaults to ''.
+    suffix (str, optional):
+        Suffix to progress bar. Defaults to ''.
+    decimals (int, optional):
+        Number of decimals to display in percentage. Defaults to 1.
+    length (int, optional):
+        Length of the progressbar. Defaults to 100.
+    fill (str, optional):
+        Progressbar fill symbol. Defaults to '\u2588' █.
+    end (str, optional):
+        Progressbar terminator. Defaults to "\r".
+    """
+
+    percent = ('{0:.' + f'{decimals}' + 'f}').format(100 * (iteration / float(total)))
+    filledLength = int(length * iteration // total)
+    bar = fill * filledLength + '-' * (length - filledLength)
+    color = Fore.LIGHTYELLOW_EX if round(filledLength / length, 1) < 1 else Fore.GREEN
+
+    print(color + f'\r{prefix} |{bar}| {percent}% {suffix}' + Style.RESET_ALL, end=end)
+
+
+def connect_centroids(cam_prefix: str) -> list[PV]:
+    """
+    Connect to the centroid PVs and wait for the PV objects to connect.
+    Parameters
+    -----------
+    cam_prefix (str):
+        Camera PV prefix.
+    Returns
+    -----------
+    list[epics.PV]:
+        list of the PV objects
+    """
+    centroid_x = PV(f'{cam_prefix}Stats2:CentroidX_RBV', connection_timeout=5)
+    centroid_y = PV(f'{cam_prefix}Stats2:CentroidY_RBV', connection_timeout=5)
+
+    centroid_x.wait_for_connection()
path: scripts/pointing_stability.py
user: login: KaushikMalapati
body: Does it make sense to have a timeout here so this can't indefinitely hang?
created_at: 2025-04-26T04:12:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,344 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import sys
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal lenght of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-r', '--near_field', action='store_true',
+                        default=False,
+                        help='Whether to perform calculations assuming near field imaging.')
+    parser.add_argument('-s', '--size', type=int,
+                        default=5000,
+                        help='Total number of centroids to acquire before calculating'
+                        ' the pointing stability. Defaults to 5000 counts')
+    return parser
+
+
+def build_data(data: list, value, **kw):
+    """Simple callback for appending camonitor output to a list"""
+    data.append(value)
+
+
+def print_progress_bar(iteration: int, total: int,
+                       prefix: str = '', suffix: str = '', decimals: int = 1,
+                       length: int = 100, fill: str = '\u2588',
+                       end: str = "\r") -> None:
+    """
+    Print the progressbar to terminal
+    Parameters
+    -----------
+    iteration (int):
+        iterator
+    total (int):
+        total number of iterations
+    prefix (str, optional):
+        Prefix to progress bar. Defaults to ''.
+    suffix (str, optional):
+        Suffix to progress bar. Defaults to ''.
+    decimals (int, optional):
+        Number of decimals to display in percentage. Defaults to 1.
+    length (int, optional):
+        Length of the progressbar. Defaults to 100.
+    fill (str, optional):
+        Progressbar fill symbol. Defaults to '\u2588' █.
+    end (str, optional):
+        Progressbar terminator. Defaults to "\r".
+    """
+
+    percent = ('{0:.' + f'{decimals}' + 'f}').format(100 * (iteration / float(total)))
+    filledLength = int(length * iteration // total)
+    bar = fill * filledLength + '-' * (length - filledLength)
+    color = Fore.LIGHTYELLOW_EX if round(filledLength / length, 1) < 1 else Fore.GREEN
+
+    print(color + f'\r{prefix} |{bar}| {percent}% {suffix}' + Style.RESET_ALL, end=end)
+
+
+def connect_centroids(cam_prefix: str) -> list[PV]:
+    """
+    Connect to the centroid PVs and wait for the PV objects to connect.
+    Parameters
+    -----------
+    cam_prefix (str):
+        Camera PV prefix.
+    Returns
+    -----------
+    list[epics.PV]:
+        list of the PV objects
+    """
+    centroid_x = PV(f'{cam_prefix}Stats2:CentroidX_RBV', connection_timeout=5)
+    centroid_y = PV(f'{cam_prefix}Stats2:CentroidY_RBV', connection_timeout=5)
+
+    centroid_x.wait_for_connection()
+    centroid_y.wait_for_connection()
+
+    return centroid_x, centroid_y
+
+
+def main():
+    # Build parser and initialize lists
+    parser = build_parser()
+    args = parser.parse_args()
+
+    # Make sure the prefix matches AD standard
+    camera = args.cam + ':' if args.cam[-1] != ':' else args.cam
+    model = PV(f'{camera}Model_RBV', connection_timeout=5).get()
+    if not model:
+        raise Exception(f'Could not connect to {camera}Model_RBV.')
+    # Make sure you're capturing if the bin is != 1
+    # First check the source of the stats plugin
+    stats_input_port = PV(f'{camera}Stats2:NDArrayPort', connection_timeout=5).get()
+    port = camera if 'CAM' in stats_input_port else f'{camera}{stats_input_port}:'
+    bin_x = PV(f'{port}BinX_RBV', connection_timeout=5).get()
+    bin_y = PV(f'{port}BinX_RBV', connection_timeout=5).get()
path: scripts/pointing_stability.py
user: login: KaushikMalapati
body: Are these pvs guaranteed to exist for all cameras?
created_at: 2025-04-26T04:14:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,344 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import sys
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal lenght of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-r', '--near_field', action='store_true',
+                        default=False,
+                        help='Whether to perform calculations assuming near field imaging.')
+    parser.add_argument('-s', '--size', type=int,
+                        default=5000,
+                        help='Total number of centroids to acquire before calculating'
+                        ' the pointing stability. Defaults to 5000 counts')
+    return parser
+
+
+def build_data(data: list, value, **kw):
+    """Simple callback for appending camonitor output to a list"""
+    data.append(value)
+
+
+def print_progress_bar(iteration: int, total: int,
+                       prefix: str = '', suffix: str = '', decimals: int = 1,
+                       length: int = 100, fill: str = '\u2588',
+                       end: str = "\r") -> None:
+    """
+    Print the progressbar to terminal
+    Parameters
+    -----------
+    iteration (int):
+        iterator
+    total (int):
+        total number of iterations
+    prefix (str, optional):
+        Prefix to progress bar. Defaults to ''.
+    suffix (str, optional):
+        Suffix to progress bar. Defaults to ''.
+    decimals (int, optional):
+        Number of decimals to display in percentage. Defaults to 1.
+    length (int, optional):
+        Length of the progressbar. Defaults to 100.
+    fill (str, optional):
+        Progressbar fill symbol. Defaults to '\u2588' █.
+    end (str, optional):
+        Progressbar terminator. Defaults to "\r".
+    """
+
+    percent = ('{0:.' + f'{decimals}' + 'f}').format(100 * (iteration / float(total)))
+    filledLength = int(length * iteration // total)
+    bar = fill * filledLength + '-' * (length - filledLength)
+    color = Fore.LIGHTYELLOW_EX if round(filledLength / length, 1) < 1 else Fore.GREEN
+
+    print(color + f'\r{prefix} |{bar}| {percent}% {suffix}' + Style.RESET_ALL, end=end)
+
+
+def connect_centroids(cam_prefix: str) -> list[PV]:
+    """
+    Connect to the centroid PVs and wait for the PV objects to connect.
+    Parameters
+    -----------
+    cam_prefix (str):
+        Camera PV prefix.
+    Returns
+    -----------
+    list[epics.PV]:
+        list of the PV objects
+    """
+    centroid_x = PV(f'{cam_prefix}Stats2:CentroidX_RBV', connection_timeout=5)
+    centroid_y = PV(f'{cam_prefix}Stats2:CentroidY_RBV', connection_timeout=5)
+
+    centroid_x.wait_for_connection()
+    centroid_y.wait_for_connection()
+
+    return centroid_x, centroid_y
+
+
+def main():
+    # Build parser and initialize lists
+    parser = build_parser()
+    args = parser.parse_args()
+
+    # Make sure the prefix matches AD standard
+    camera = args.cam + ':' if args.cam[-1] != ':' else args.cam
+    model = PV(f'{camera}Model_RBV', connection_timeout=5).get()
+    if not model:
+        raise Exception(f'Could not connect to {camera}Model_RBV.')
+    # Make sure you're capturing if the bin is != 1
+    # First check the source of the stats plugin
+    stats_input_port = PV(f'{camera}Stats2:NDArrayPort', connection_timeout=5).get()
+    port = camera if 'CAM' in stats_input_port else f'{camera}{stats_input_port}:'
+    bin_x = PV(f'{port}BinX_RBV', connection_timeout=5).get()
+    bin_y = PV(f'{port}BinX_RBV', connection_timeout=5).get()
+    # Initialize lists and connect to centroid PVs
+    total = 5000
+    if args.size:
+        total = args.size
path: scripts/pointing_stability.py
user: login: KaushikMalapati
body: ```suggestion
total = args.size
```
args.size has a default value of 5000
created_at: 2025-04-26T04:15:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,344 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import sys
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal lenght of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-r', '--near_field', action='store_true',
+                        default=False,
+                        help='Whether to perform calculations assuming near field imaging.')
+    parser.add_argument('-s', '--size', type=int,
+                        default=5000,
+                        help='Total number of centroids to acquire before calculating'
+                        ' the pointing stability. Defaults to 5000 counts')
+    return parser
+
+
+def build_data(data: list, value, **kw):
+    """Simple callback for appending camonitor output to a list"""
+    data.append(value)
+
+
+def print_progress_bar(iteration: int, total: int,
+                       prefix: str = '', suffix: str = '', decimals: int = 1,
+                       length: int = 100, fill: str = '\u2588',
+                       end: str = "\r") -> None:
+    """
+    Print the progressbar to terminal
+    Parameters
+    -----------
+    iteration (int):
+        iterator
+    total (int):
+        total number of iterations
+    prefix (str, optional):
+        Prefix to progress bar. Defaults to ''.
+    suffix (str, optional):
+        Suffix to progress bar. Defaults to ''.
+    decimals (int, optional):
+        Number of decimals to display in percentage. Defaults to 1.
+    length (int, optional):
+        Length of the progressbar. Defaults to 100.
+    fill (str, optional):
+        Progressbar fill symbol. Defaults to '\u2588' █.
+    end (str, optional):
+        Progressbar terminator. Defaults to "\r".
+    """
+
+    percent = ('{0:.' + f'{decimals}' + 'f}').format(100 * (iteration / float(total)))
+    filledLength = int(length * iteration // total)
+    bar = fill * filledLength + '-' * (length - filledLength)
+    color = Fore.LIGHTYELLOW_EX if round(filledLength / length, 1) < 1 else Fore.GREEN
+
+    print(color + f'\r{prefix} |{bar}| {percent}% {suffix}' + Style.RESET_ALL, end=end)
+
+
+def connect_centroids(cam_prefix: str) -> list[PV]:
+    """
+    Connect to the centroid PVs and wait for the PV objects to connect.
+    Parameters
+    -----------
+    cam_prefix (str):
+        Camera PV prefix.
+    Returns
+    -----------
+    list[epics.PV]:
+        list of the PV objects
+    """
+    centroid_x = PV(f'{cam_prefix}Stats2:CentroidX_RBV', connection_timeout=5)
+    centroid_y = PV(f'{cam_prefix}Stats2:CentroidY_RBV', connection_timeout=5)
+
+    centroid_x.wait_for_connection()
+    centroid_y.wait_for_connection()
+
+    return centroid_x, centroid_y
+
+
+def main():
+    # Build parser and initialize lists
+    parser = build_parser()
+    args = parser.parse_args()
+
+    # Make sure the prefix matches AD standard
+    camera = args.cam + ':' if args.cam[-1] != ':' else args.cam
+    model = PV(f'{camera}Model_RBV', connection_timeout=5).get()
+    if not model:
+        raise Exception(f'Could not connect to {camera}Model_RBV.')
+    # Make sure you're capturing if the bin is != 1
+    # First check the source of the stats plugin
+    stats_input_port = PV(f'{camera}Stats2:NDArrayPort', connection_timeout=5).get()
+    port = camera if 'CAM' in stats_input_port else f'{camera}{stats_input_port}:'
+    bin_x = PV(f'{port}BinX_RBV', connection_timeout=5).get()
+    bin_y = PV(f'{port}BinX_RBV', connection_timeout=5).get()
+    # Initialize lists and connect to centroid PVs
+    total = 5000
+    if args.size:
+        total = args.size
+    x_samples = []
+    y_samples = []
+
+    centroid_x_pv, centroid_y_pv = connect_centroids(camera)
+
+    # Collect data
+    centroid_x_pv.add_callback(partial(build_data, x_samples))
+    centroid_y_pv.add_callback(partial(build_data, y_samples))
+
+    while min(len(x_samples), len(y_samples)) < total:
+        if not args.quiet:
+            print_progress_bar(min(len(x_samples), len(y_samples)) + 1,
+                               total=total,
+                               prefix='Collecting data...', fill="\u2588")
+        time.sleep(0.1)
+
+    # Remove callback after collection
+    centroid_x_pv.remove_callback(1)
+    centroid_y_pv.remove_callback(1)
+
+    # Convert focal length to micron
+    focal_length = 10.0*1E4
+    if args.focal_length:
+        focal_length = args.focal_length*1E4
path: scripts/pointing_stability.py
user: login: KaushikMalapati
body: ```suggestion
    focal_length = args.focal_length*1E4
```
created_at: 2025-04-26T04:34:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,344 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import sys
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal lenght of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-r', '--near_field', action='store_true',
+                        default=False,
+                        help='Whether to perform calculations assuming near field imaging.')
+    parser.add_argument('-s', '--size', type=int,
+                        default=5000,
+                        help='Total number of centroids to acquire before calculating'
+                        ' the pointing stability. Defaults to 5000 counts')
+    return parser
+
+
+def build_data(data: list, value, **kw):
+    """Simple callback for appending camonitor output to a list"""
+    data.append(value)
+
+
+def print_progress_bar(iteration: int, total: int,
+                       prefix: str = '', suffix: str = '', decimals: int = 1,
+                       length: int = 100, fill: str = '\u2588',
+                       end: str = "\r") -> None:
+    """
+    Print the progressbar to terminal
+    Parameters
+    -----------
+    iteration (int):
+        iterator
+    total (int):
+        total number of iterations
+    prefix (str, optional):
+        Prefix to progress bar. Defaults to ''.
+    suffix (str, optional):
+        Suffix to progress bar. Defaults to ''.
+    decimals (int, optional):
+        Number of decimals to display in percentage. Defaults to 1.
+    length (int, optional):
+        Length of the progressbar. Defaults to 100.
+    fill (str, optional):
+        Progressbar fill symbol. Defaults to '\u2588' █.
+    end (str, optional):
+        Progressbar terminator. Defaults to "\r".
+    """
+
+    percent = ('{0:.' + f'{decimals}' + 'f}').format(100 * (iteration / float(total)))
+    filledLength = int(length * iteration // total)
+    bar = fill * filledLength + '-' * (length - filledLength)
+    color = Fore.LIGHTYELLOW_EX if round(filledLength / length, 1) < 1 else Fore.GREEN
+
+    print(color + f'\r{prefix} |{bar}| {percent}% {suffix}' + Style.RESET_ALL, end=end)
+
+
+def connect_centroids(cam_prefix: str) -> list[PV]:
+    """
+    Connect to the centroid PVs and wait for the PV objects to connect.
+    Parameters
+    -----------
+    cam_prefix (str):
+        Camera PV prefix.
+    Returns
+    -----------
+    list[epics.PV]:
+        list of the PV objects
+    """
+    centroid_x = PV(f'{cam_prefix}Stats2:CentroidX_RBV', connection_timeout=5)
+    centroid_y = PV(f'{cam_prefix}Stats2:CentroidY_RBV', connection_timeout=5)
+
+    centroid_x.wait_for_connection()
+    centroid_y.wait_for_connection()
+
+    return centroid_x, centroid_y
+
+
+def main():
+    # Build parser and initialize lists
+    parser = build_parser()
+    args = parser.parse_args()
+
+    # Make sure the prefix matches AD standard
+    camera = args.cam + ':' if args.cam[-1] != ':' else args.cam
+    model = PV(f'{camera}Model_RBV', connection_timeout=5).get()
+    if not model:
+        raise Exception(f'Could not connect to {camera}Model_RBV.')
+    # Make sure you're capturing if the bin is != 1
+    # First check the source of the stats plugin
+    stats_input_port = PV(f'{camera}Stats2:NDArrayPort', connection_timeout=5).get()
+    port = camera if 'CAM' in stats_input_port else f'{camera}{stats_input_port}:'
+    bin_x = PV(f'{port}BinX_RBV', connection_timeout=5).get()
+    bin_y = PV(f'{port}BinX_RBV', connection_timeout=5).get()
+    # Initialize lists and connect to centroid PVs
+    total = 5000
+    if args.size:
+        total = args.size
+    x_samples = []
+    y_samples = []
+
+    centroid_x_pv, centroid_y_pv = connect_centroids(camera)
+
+    # Collect data
+    centroid_x_pv.add_callback(partial(build_data, x_samples))
+    centroid_y_pv.add_callback(partial(build_data, y_samples))
+
+    while min(len(x_samples), len(y_samples)) < total:
+        if not args.quiet:
+            print_progress_bar(min(len(x_samples), len(y_samples)) + 1,
+                               total=total,
+                               prefix='Collecting data...', fill="\u2588")
+        time.sleep(0.1)
+
+    # Remove callback after collection
+    centroid_x_pv.remove_callback(1)
+    centroid_y_pv.remove_callback(1)
+
+    # Convert focal length to micron
+    focal_length = 10.0*1E4
+    if args.focal_length:
+        focal_length = args.focal_length*1E4
+
+    # Trim to same array lengths if necessary
+    if len(x_samples) != len(y_samples):
+        _length = min(len(x_samples), len(y_samples))
+        x_samples = x_samples[:_length]
+        y_samples = y_samples[:_length]
+
+    # Convert from pixels to real dimensions using vendor's data and account for binning
+    if args.pixel_size:
+        pixel_size = args.pixel_size
+    else:
+        pixel_size = PIXEL_DICT[model]
+    x_samples_um = np.asarray(x_samples)*pixel_size*bin_x
+    y_samples_um = np.asarray(y_samples)*pixel_size*bin_y
+
+    # Now let's do some fun math
path: scripts/pointing_stability.py
user: login: tangkong
body: For reusability/testing it's probably sensible to refactor the math into a separate function.  Right now the bulk of the logic is in `main()`, and it would be difficult to run this protocol with simulated or stored data.
created_at: 2025-04-28T16:43:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,344 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import sys
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal lenght of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-r', '--near_field', action='store_true',
+                        default=False,
+                        help='Whether to perform calculations assuming near field imaging.')
+    parser.add_argument('-s', '--size', type=int,
+                        default=5000,
+                        help='Total number of centroids to acquire before calculating'
+                        ' the pointing stability. Defaults to 5000 counts')
+    return parser
+
+
+def build_data(data: list, value, **kw):
+    """Simple callback for appending camonitor output to a list"""
+    data.append(value)
+
+
+def print_progress_bar(iteration: int, total: int,
+                       prefix: str = '', suffix: str = '', decimals: int = 1,
+                       length: int = 100, fill: str = '\u2588',
+                       end: str = "\r") -> None:
+    """
+    Print the progressbar to terminal
+    Parameters
+    -----------
+    iteration (int):
+        iterator
+    total (int):
+        total number of iterations
+    prefix (str, optional):
+        Prefix to progress bar. Defaults to ''.
+    suffix (str, optional):
+        Suffix to progress bar. Defaults to ''.
+    decimals (int, optional):
+        Number of decimals to display in percentage. Defaults to 1.
+    length (int, optional):
+        Length of the progressbar. Defaults to 100.
+    fill (str, optional):
+        Progressbar fill symbol. Defaults to '\u2588' █.
+    end (str, optional):
+        Progressbar terminator. Defaults to "\r".
+    """
+
+    percent = ('{0:.' + f'{decimals}' + 'f}').format(100 * (iteration / float(total)))
+    filledLength = int(length * iteration // total)
+    bar = fill * filledLength + '-' * (length - filledLength)
+    color = Fore.LIGHTYELLOW_EX if round(filledLength / length, 1) < 1 else Fore.GREEN
+
+    print(color + f'\r{prefix} |{bar}| {percent}% {suffix}' + Style.RESET_ALL, end=end)
+
+
+def connect_centroids(cam_prefix: str) -> list[PV]:
+    """
+    Connect to the centroid PVs and wait for the PV objects to connect.
+    Parameters
+    -----------
+    cam_prefix (str):
+        Camera PV prefix.
+    Returns
+    -----------
+    list[epics.PV]:
+        list of the PV objects
+    """
+    centroid_x = PV(f'{cam_prefix}Stats2:CentroidX_RBV', connection_timeout=5)
+    centroid_y = PV(f'{cam_prefix}Stats2:CentroidY_RBV', connection_timeout=5)
+
+    centroid_x.wait_for_connection()
+    centroid_y.wait_for_connection()
+
+    return centroid_x, centroid_y
+
+
+def main():
+    # Build parser and initialize lists
+    parser = build_parser()
+    args = parser.parse_args()
+
+    # Make sure the prefix matches AD standard
+    camera = args.cam + ':' if args.cam[-1] != ':' else args.cam
+    model = PV(f'{camera}Model_RBV', connection_timeout=5).get()
+    if not model:
+        raise Exception(f'Could not connect to {camera}Model_RBV.')
+    # Make sure you're capturing if the bin is != 1
+    # First check the source of the stats plugin
+    stats_input_port = PV(f'{camera}Stats2:NDArrayPort', connection_timeout=5).get()
+    port = camera if 'CAM' in stats_input_port else f'{camera}{stats_input_port}:'
+    bin_x = PV(f'{port}BinX_RBV', connection_timeout=5).get()
+    bin_y = PV(f'{port}BinX_RBV', connection_timeout=5).get()
+    # Initialize lists and connect to centroid PVs
+    total = 5000
+    if args.size:
+        total = args.size
+    x_samples = []
+    y_samples = []
+
+    centroid_x_pv, centroid_y_pv = connect_centroids(camera)
+
+    # Collect data
+    centroid_x_pv.add_callback(partial(build_data, x_samples))
+    centroid_y_pv.add_callback(partial(build_data, y_samples))
+
+    while min(len(x_samples), len(y_samples)) < total:
+        if not args.quiet:
+            print_progress_bar(min(len(x_samples), len(y_samples)) + 1,
+                               total=total,
+                               prefix='Collecting data...', fill="\u2588")
+        time.sleep(0.1)
+
+    # Remove callback after collection
+    centroid_x_pv.remove_callback(1)
+    centroid_y_pv.remove_callback(1)
+
+    # Convert focal length to micron
+    focal_length = 10.0*1E4
+    if args.focal_length:
+        focal_length = args.focal_length*1E4
+
+    # Trim to same array lengths if necessary
+    if len(x_samples) != len(y_samples):
+        _length = min(len(x_samples), len(y_samples))
+        x_samples = x_samples[:_length]
+        y_samples = y_samples[:_length]
+
+    # Convert from pixels to real dimensions using vendor's data and account for binning
+    if args.pixel_size:
+        pixel_size = args.pixel_size
+    else:
+        pixel_size = PIXEL_DICT[model]
+    x_samples_um = np.asarray(x_samples)*pixel_size*bin_x
+    y_samples_um = np.asarray(y_samples)*pixel_size*bin_y
+
+    # Now let's do some fun math
+    x_mean = np.mean(x_samples_um)
+    y_mean = np.mean(y_samples_um)
+    x_centered = x_samples_um - x_mean
+    y_centered = y_samples_um - y_mean
+
+    x_std_dev = np.std(x_samples_um, ddof=1)
+    y_std_dev = np.std(y_samples_um, ddof=1)
+
+    positional_stability_x = 4*x_std_dev
+    positional_stability_y = 4*y_std_dev
+    pos_stabilities = [positional_stability_x, positional_stability_y]
+
+    # Now let's look at angular displacements
+    if not args.near_field:
+        theta_x = np.arctan(x_centered/focal_length)*1E6
+        theta_y = np.arctan(y_centered/focal_length)*1E6
+
+        theta_x_avg = np.mean(theta_x)
+        theta_y_avg = np.mean(theta_y)
+
+        theta_x_std = np.std(theta_x, ddof=1)
+        theta_y_std = np.std(theta_y, ddof=1)
+
+        theta_means = [theta_x_avg, theta_y_avg]
+        theta_stds = [theta_x_std, theta_y_std]
+
+    if not args.quiet:
+        print('\nPositional stability \u0394 :\n\t'
+              + '('
+              + ', '.join([f'{_s:2.3e}'
+                          for _s in pos_stabilities]
+                          )
+              + ') \u03bcm')
+
+    if not args.near_field and not args.quiet:
+        # Print (Θ_x ± σ_x, Θ_y ± σ_y)
+        print('Angular pointing:\n\t'
+              + '('
+              + ', '.join([f'{_m:2.3e} \u00b1 {_std:2.3e}'
+                          for _m, _std in zip(theta_means, theta_stds)]
+                          )
+              + ') \u03bcrad')
+    # #-----------------------------------------------------------------------------------------# #
+    # Plotting outputs
+    # #-----------------------------------------------------------------------------------------# #
+    # Positional displacement subplot
+    num_plots = 3
+    if args.near_field:
+        num_plots = 2
+    fig, axs = plt.subplots(num_plots,
+                            height_ratios=(num_plots-1)*[1]+[0.5])
+    axs[0].scatter(x_centered, y_centered)
+    axs[0].axhline(y=0, color='black', linestyle='--')
+    axs[0].axvline(x=0, color='black', linestyle='--')
+    axs[0].set_xlabel('X displacement (\u03bcm)')
+    axs[0].set_ylabel('Y displacement (\u03bcm)')
+    axs[0].set_box_aspect(1)
+    # Stat data for table printing
+    col_labels = ['X', 'Y']
+    row_labels = ['\u0394 (\u03bcm)']
+    table_data = [pos_stabilities]
+    # Angular displacement subplot
+    if not args.near_field:
+        axs[1].hist(theta_x, bins='fd',
+                    fc=(0, 0, 1, 0.5),
+                    edgecolor='black',
+                    label='X')
+        axs[1].hist(theta_y, bins='fd',
+                    fc=(1, 0.25, 0.25, 0.5),
+                    edgecolor='black',
+                    label='Y')
+        axs[1].legend(loc='upper right')
+        axs[1].set_xlabel("Pointing (\u03bcrad)")
+        axs[1].set_ylabel("Counts")
+        axs[1].set_box_aspect(1)
+        row_labels = row_labels + ['\u03b8_avg (\u03bcrad)', '\u03b8 std_dev (\u03bcrad)']
+        table_data = table_data + [theta_means, theta_stds]
+    # Now add the table
+    table_data = [[f'{_s:2.3e}' for _s in _ls] for _ls in table_data]
+    axs[-1].table(cellText=table_data,
+                  cellLoc='center',
+                  loc='center',
+                  rowLabels=row_labels,
+                  colLabels=col_labels
+                  )
+    axs[-1].axis('Off')
+    plt.suptitle(f'{camera}')
+    plt.subplots_adjust(hspace=0.4)
+    fig.set_figheight(10)
+    fig.set_figwidth(10)
+    plt.tight_layout()
+    if not args.quiet:
+        plt.show()
+
+    # Format the data for saving
+
+    camera_header = (f"{camera.replace(':', '_').lower()}_imaging_distance_{focal_length*1E-4}"
+                     + '\nx_centered (\u03bcm),y_centered (\u03bcm)')
+    data = np.concatenate((x_centered, y_centered))
+    data = np.resize(data, (2, total))
+
+    timestamp = time.strftime('D%Y_%m_%d_T%H_%M_%S')
+
+    _dir = os.getcwd()
+    # Use the user-specified directory and create if it does not exist
+    if args.dir:
+        _dir = args.dir
+        if not os.path.exists(_dir):
+            os.makedirs(_dir)
+
+    # Set a default file name and use the user-specified one if it supplied
+    file_dest = f"{_dir}/data_{camera.replace(':', '_').lower()}{timestamp}"
+    if args.filename:
+        file_dest = f"{_dir}/{args.filename}"
+
+    np.savetxt(f"{file_dest}.csv", np.transpose(data), delimiter=',', header=camera_header)
+    fig.savefig(f"{file_dest}.png")
+
+    sys.exit(0)
path: scripts/pointing_stability.py
user: login: aberges-SLAC
body: Yeah, the explicit `sys.exit(0)` call can go. Doesn't serve any purpose — I should just `Raise` in cases where I want the script to fail in the first place.
created_at: 2025-04-28T18:38:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,344 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import sys
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal lenght of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.')
path: scripts/pointing_stability.py
user: login: aberges-SLAC
body: Ah! Great catch, thank you
created_at: 2025-04-28T18:38:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,344 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import sys
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal lenght of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-r', '--near_field', action='store_true',
+                        default=False,
+                        help='Whether to perform calculations assuming near field imaging.')
+    parser.add_argument('-s', '--size', type=int,
+                        default=5000,
+                        help='Total number of centroids to acquire before calculating'
+                        ' the pointing stability. Defaults to 5000 counts')
+    return parser
+
+
+def build_data(data: list, value, **kw):
+    """Simple callback for appending camonitor output to a list"""
+    data.append(value)
+
+
+def print_progress_bar(iteration: int, total: int,
+                       prefix: str = '', suffix: str = '', decimals: int = 1,
+                       length: int = 100, fill: str = '\u2588',
+                       end: str = "\r") -> None:
+    """
+    Print the progressbar to terminal
+    Parameters
+    -----------
+    iteration (int):
+        iterator
+    total (int):
+        total number of iterations
+    prefix (str, optional):
+        Prefix to progress bar. Defaults to ''.
+    suffix (str, optional):
+        Suffix to progress bar. Defaults to ''.
+    decimals (int, optional):
+        Number of decimals to display in percentage. Defaults to 1.
+    length (int, optional):
+        Length of the progressbar. Defaults to 100.
+    fill (str, optional):
+        Progressbar fill symbol. Defaults to '\u2588' █.
+    end (str, optional):
+        Progressbar terminator. Defaults to "\r".
+    """
+
+    percent = ('{0:.' + f'{decimals}' + 'f}').format(100 * (iteration / float(total)))
+    filledLength = int(length * iteration // total)
+    bar = fill * filledLength + '-' * (length - filledLength)
+    color = Fore.LIGHTYELLOW_EX if round(filledLength / length, 1) < 1 else Fore.GREEN
+
+    print(color + f'\r{prefix} |{bar}| {percent}% {suffix}' + Style.RESET_ALL, end=end)
+
+
+def connect_centroids(cam_prefix: str) -> list[PV]:
+    """
+    Connect to the centroid PVs and wait for the PV objects to connect.
+    Parameters
+    -----------
+    cam_prefix (str):
+        Camera PV prefix.
+    Returns
+    -----------
+    list[epics.PV]:
+        list of the PV objects
+    """
+    centroid_x = PV(f'{cam_prefix}Stats2:CentroidX_RBV', connection_timeout=5)
+    centroid_y = PV(f'{cam_prefix}Stats2:CentroidY_RBV', connection_timeout=5)
+
+    centroid_x.wait_for_connection()
+    centroid_y.wait_for_connection()
+
+    return centroid_x, centroid_y
+
+
+def main():
+    # Build parser and initialize lists
+    parser = build_parser()
+    args = parser.parse_args()
+
+    # Make sure the prefix matches AD standard
+    camera = args.cam + ':' if args.cam[-1] != ':' else args.cam
+    model = PV(f'{camera}Model_RBV', connection_timeout=5).get()
path: scripts/pointing_stability.py
user: login: aberges-SLAC
body: Agreed, no reason to instantiate another `PV` class for this
created_at: 2025-04-28T18:39:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,344 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import sys
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal lenght of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-r', '--near_field', action='store_true',
+                        default=False,
+                        help='Whether to perform calculations assuming near field imaging.')
+    parser.add_argument('-s', '--size', type=int,
+                        default=5000,
+                        help='Total number of centroids to acquire before calculating'
+                        ' the pointing stability. Defaults to 5000 counts')
+    return parser
+
+
+def build_data(data: list, value, **kw):
+    """Simple callback for appending camonitor output to a list"""
+    data.append(value)
+
+
+def print_progress_bar(iteration: int, total: int,
+                       prefix: str = '', suffix: str = '', decimals: int = 1,
+                       length: int = 100, fill: str = '\u2588',
+                       end: str = "\r") -> None:
+    """
+    Print the progressbar to terminal
+    Parameters
+    -----------
+    iteration (int):
+        iterator
+    total (int):
+        total number of iterations
+    prefix (str, optional):
+        Prefix to progress bar. Defaults to ''.
+    suffix (str, optional):
+        Suffix to progress bar. Defaults to ''.
+    decimals (int, optional):
+        Number of decimals to display in percentage. Defaults to 1.
+    length (int, optional):
+        Length of the progressbar. Defaults to 100.
+    fill (str, optional):
+        Progressbar fill symbol. Defaults to '\u2588' █.
+    end (str, optional):
+        Progressbar terminator. Defaults to "\r".
+    """
+
+    percent = ('{0:.' + f'{decimals}' + 'f}').format(100 * (iteration / float(total)))
+    filledLength = int(length * iteration // total)
+    bar = fill * filledLength + '-' * (length - filledLength)
+    color = Fore.LIGHTYELLOW_EX if round(filledLength / length, 1) < 1 else Fore.GREEN
+
+    print(color + f'\r{prefix} |{bar}| {percent}% {suffix}' + Style.RESET_ALL, end=end)
+
+
+def connect_centroids(cam_prefix: str) -> list[PV]:
+    """
+    Connect to the centroid PVs and wait for the PV objects to connect.
+    Parameters
+    -----------
+    cam_prefix (str):
+        Camera PV prefix.
+    Returns
+    -----------
+    list[epics.PV]:
+        list of the PV objects
+    """
+    centroid_x = PV(f'{cam_prefix}Stats2:CentroidX_RBV', connection_timeout=5)
+    centroid_y = PV(f'{cam_prefix}Stats2:CentroidY_RBV', connection_timeout=5)
+
+    centroid_x.wait_for_connection()
path: scripts/pointing_stability.py
user: login: aberges-SLAC
body: Absolutely, this should've been:
```
centroid_x.wait_for_connection(timeout=5)
centroid_y.wait_for_connection(timeout=5)
```
created_at: 2025-04-28T18:40:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,344 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import sys
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal lenght of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-r', '--near_field', action='store_true',
+                        default=False,
+                        help='Whether to perform calculations assuming near field imaging.')
+    parser.add_argument('-s', '--size', type=int,
+                        default=5000,
+                        help='Total number of centroids to acquire before calculating'
+                        ' the pointing stability. Defaults to 5000 counts')
+    return parser
+
+
+def build_data(data: list, value, **kw):
+    """Simple callback for appending camonitor output to a list"""
+    data.append(value)
+
+
+def print_progress_bar(iteration: int, total: int,
+                       prefix: str = '', suffix: str = '', decimals: int = 1,
+                       length: int = 100, fill: str = '\u2588',
+                       end: str = "\r") -> None:
+    """
+    Print the progressbar to terminal
+    Parameters
+    -----------
+    iteration (int):
+        iterator
+    total (int):
+        total number of iterations
+    prefix (str, optional):
+        Prefix to progress bar. Defaults to ''.
+    suffix (str, optional):
+        Suffix to progress bar. Defaults to ''.
+    decimals (int, optional):
+        Number of decimals to display in percentage. Defaults to 1.
+    length (int, optional):
+        Length of the progressbar. Defaults to 100.
+    fill (str, optional):
+        Progressbar fill symbol. Defaults to '\u2588' █.
+    end (str, optional):
+        Progressbar terminator. Defaults to "\r".
+    """
+
+    percent = ('{0:.' + f'{decimals}' + 'f}').format(100 * (iteration / float(total)))
+    filledLength = int(length * iteration // total)
+    bar = fill * filledLength + '-' * (length - filledLength)
+    color = Fore.LIGHTYELLOW_EX if round(filledLength / length, 1) < 1 else Fore.GREEN
+
+    print(color + f'\r{prefix} |{bar}| {percent}% {suffix}' + Style.RESET_ALL, end=end)
+
+
+def connect_centroids(cam_prefix: str) -> list[PV]:
+    """
+    Connect to the centroid PVs and wait for the PV objects to connect.
+    Parameters
+    -----------
+    cam_prefix (str):
+        Camera PV prefix.
+    Returns
+    -----------
+    list[epics.PV]:
+        list of the PV objects
+    """
+    centroid_x = PV(f'{cam_prefix}Stats2:CentroidX_RBV', connection_timeout=5)
+    centroid_y = PV(f'{cam_prefix}Stats2:CentroidY_RBV', connection_timeout=5)
+
+    centroid_x.wait_for_connection()
+    centroid_y.wait_for_connection()
+
+    return centroid_x, centroid_y
+
+
+def main():
+    # Build parser and initialize lists
+    parser = build_parser()
+    args = parser.parse_args()
+
+    # Make sure the prefix matches AD standard
+    camera = args.cam + ':' if args.cam[-1] != ':' else args.cam
+    model = PV(f'{camera}Model_RBV', connection_timeout=5).get()
+    if not model:
+        raise Exception(f'Could not connect to {camera}Model_RBV.')
+    # Make sure you're capturing if the bin is != 1
+    # First check the source of the stats plugin
+    stats_input_port = PV(f'{camera}Stats2:NDArrayPort', connection_timeout=5).get()
+    port = camera if 'CAM' in stats_input_port else f'{camera}{stats_input_port}:'
+    bin_x = PV(f'{port}BinX_RBV', connection_timeout=5).get()
+    bin_y = PV(f'{port}BinX_RBV', connection_timeout=5).get()
path: scripts/pointing_stability.py
user: login: aberges-SLAC
body: For all cameras using `ioc-common-gigecam`, I'm pretty certain. They're automatically generated by the array ports and get quite confusing if you're not tracing the inputs of other plugins
created_at: 2025-04-28T18:42:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,344 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import sys
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal lenght of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-r', '--near_field', action='store_true',
+                        default=False,
+                        help='Whether to perform calculations assuming near field imaging.')
+    parser.add_argument('-s', '--size', type=int,
+                        default=5000,
+                        help='Total number of centroids to acquire before calculating'
+                        ' the pointing stability. Defaults to 5000 counts')
+    return parser
+
+
+def build_data(data: list, value, **kw):
+    """Simple callback for appending camonitor output to a list"""
+    data.append(value)
+
+
+def print_progress_bar(iteration: int, total: int,
+                       prefix: str = '', suffix: str = '', decimals: int = 1,
+                       length: int = 100, fill: str = '\u2588',
+                       end: str = "\r") -> None:
+    """
+    Print the progressbar to terminal
+    Parameters
+    -----------
+    iteration (int):
+        iterator
+    total (int):
+        total number of iterations
+    prefix (str, optional):
+        Prefix to progress bar. Defaults to ''.
+    suffix (str, optional):
+        Suffix to progress bar. Defaults to ''.
+    decimals (int, optional):
+        Number of decimals to display in percentage. Defaults to 1.
+    length (int, optional):
+        Length of the progressbar. Defaults to 100.
+    fill (str, optional):
+        Progressbar fill symbol. Defaults to '\u2588' █.
+    end (str, optional):
+        Progressbar terminator. Defaults to "\r".
+    """
+
+    percent = ('{0:.' + f'{decimals}' + 'f}').format(100 * (iteration / float(total)))
+    filledLength = int(length * iteration // total)
+    bar = fill * filledLength + '-' * (length - filledLength)
+    color = Fore.LIGHTYELLOW_EX if round(filledLength / length, 1) < 1 else Fore.GREEN
+
+    print(color + f'\r{prefix} |{bar}| {percent}% {suffix}' + Style.RESET_ALL, end=end)
+
+
+def connect_centroids(cam_prefix: str) -> list[PV]:
+    """
+    Connect to the centroid PVs and wait for the PV objects to connect.
+    Parameters
+    -----------
+    cam_prefix (str):
+        Camera PV prefix.
+    Returns
+    -----------
+    list[epics.PV]:
+        list of the PV objects
+    """
+    centroid_x = PV(f'{cam_prefix}Stats2:CentroidX_RBV', connection_timeout=5)
+    centroid_y = PV(f'{cam_prefix}Stats2:CentroidY_RBV', connection_timeout=5)
+
+    centroid_x.wait_for_connection()
+    centroid_y.wait_for_connection()
+
+    return centroid_x, centroid_y
+
+
+def main():
+    # Build parser and initialize lists
+    parser = build_parser()
+    args = parser.parse_args()
+
+    # Make sure the prefix matches AD standard
+    camera = args.cam + ':' if args.cam[-1] != ':' else args.cam
+    model = PV(f'{camera}Model_RBV', connection_timeout=5).get()
+    if not model:
+        raise Exception(f'Could not connect to {camera}Model_RBV.')
+    # Make sure you're capturing if the bin is != 1
+    # First check the source of the stats plugin
+    stats_input_port = PV(f'{camera}Stats2:NDArrayPort', connection_timeout=5).get()
+    port = camera if 'CAM' in stats_input_port else f'{camera}{stats_input_port}:'
+    bin_x = PV(f'{port}BinX_RBV', connection_timeout=5).get()
+    bin_y = PV(f'{port}BinX_RBV', connection_timeout=5).get()
path: scripts/pointing_stability.py
user: login: KaushikMalapati
body: I think if the pv timeouts for whatever reason bin_x/y will end up being None and throwing an error when you try using them as an operand, might be worth checking that they are not None.
created_at: 2025-04-28T18:47:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,344 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import sys
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal lenght of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-r', '--near_field', action='store_true',
+                        default=False,
+                        help='Whether to perform calculations assuming near field imaging.')
+    parser.add_argument('-s', '--size', type=int,
+                        default=5000,
+                        help='Total number of centroids to acquire before calculating'
+                        ' the pointing stability. Defaults to 5000 counts')
+    return parser
+
+
+def build_data(data: list, value, **kw):
+    """Simple callback for appending camonitor output to a list"""
+    data.append(value)
+
+
+def print_progress_bar(iteration: int, total: int,
+                       prefix: str = '', suffix: str = '', decimals: int = 1,
+                       length: int = 100, fill: str = '\u2588',
+                       end: str = "\r") -> None:
+    """
+    Print the progressbar to terminal
+    Parameters
+    -----------
+    iteration (int):
+        iterator
+    total (int):
+        total number of iterations
+    prefix (str, optional):
+        Prefix to progress bar. Defaults to ''.
+    suffix (str, optional):
+        Suffix to progress bar. Defaults to ''.
+    decimals (int, optional):
+        Number of decimals to display in percentage. Defaults to 1.
+    length (int, optional):
+        Length of the progressbar. Defaults to 100.
+    fill (str, optional):
+        Progressbar fill symbol. Defaults to '\u2588' █.
+    end (str, optional):
+        Progressbar terminator. Defaults to "\r".
+    """
+
+    percent = ('{0:.' + f'{decimals}' + 'f}').format(100 * (iteration / float(total)))
+    filledLength = int(length * iteration // total)
+    bar = fill * filledLength + '-' * (length - filledLength)
+    color = Fore.LIGHTYELLOW_EX if round(filledLength / length, 1) < 1 else Fore.GREEN
+
+    print(color + f'\r{prefix} |{bar}| {percent}% {suffix}' + Style.RESET_ALL, end=end)
+
+
+def connect_centroids(cam_prefix: str) -> list[PV]:
+    """
+    Connect to the centroid PVs and wait for the PV objects to connect.
+    Parameters
+    -----------
+    cam_prefix (str):
+        Camera PV prefix.
+    Returns
+    -----------
+    list[epics.PV]:
+        list of the PV objects
+    """
+    centroid_x = PV(f'{cam_prefix}Stats2:CentroidX_RBV', connection_timeout=5)
+    centroid_y = PV(f'{cam_prefix}Stats2:CentroidY_RBV', connection_timeout=5)
+
+    centroid_x.wait_for_connection()
+    centroid_y.wait_for_connection()
+
+    return centroid_x, centroid_y
+
+
+def main():
+    # Build parser and initialize lists
+    parser = build_parser()
+    args = parser.parse_args()
+
+    # Make sure the prefix matches AD standard
+    camera = args.cam + ':' if args.cam[-1] != ':' else args.cam
+    model = PV(f'{camera}Model_RBV', connection_timeout=5).get()
+    if not model:
+        raise Exception(f'Could not connect to {camera}Model_RBV.')
+    # Make sure you're capturing if the bin is != 1
+    # First check the source of the stats plugin
+    stats_input_port = PV(f'{camera}Stats2:NDArrayPort', connection_timeout=5).get()
+    port = camera if 'CAM' in stats_input_port else f'{camera}{stats_input_port}:'
+    bin_x = PV(f'{port}BinX_RBV', connection_timeout=5).get()
+    bin_y = PV(f'{port}BinX_RBV', connection_timeout=5).get()
+    # Initialize lists and connect to centroid PVs
+    total = 5000
+    if args.size:
+        total = args.size
+    x_samples = []
+    y_samples = []
+
+    centroid_x_pv, centroid_y_pv = connect_centroids(camera)
+
+    # Collect data
+    centroid_x_pv.add_callback(partial(build_data, x_samples))
+    centroid_y_pv.add_callback(partial(build_data, y_samples))
+
+    while min(len(x_samples), len(y_samples)) < total:
+        if not args.quiet:
+            print_progress_bar(min(len(x_samples), len(y_samples)) + 1,
+                               total=total,
+                               prefix='Collecting data...', fill="\u2588")
+        time.sleep(0.1)
+
+    # Remove callback after collection
+    centroid_x_pv.remove_callback(1)
+    centroid_y_pv.remove_callback(1)
+
+    # Convert focal length to micron
+    focal_length = 10.0*1E4
+    if args.focal_length:
+        focal_length = args.focal_length*1E4
+
+    # Trim to same array lengths if necessary
+    if len(x_samples) != len(y_samples):
+        _length = min(len(x_samples), len(y_samples))
+        x_samples = x_samples[:_length]
+        y_samples = y_samples[:_length]
+
+    # Convert from pixels to real dimensions using vendor's data and account for binning
+    if args.pixel_size:
+        pixel_size = args.pixel_size
+    else:
+        pixel_size = PIXEL_DICT[model]
+    x_samples_um = np.asarray(x_samples)*pixel_size*bin_x
+    y_samples_um = np.asarray(y_samples)*pixel_size*bin_y
+
+    # Now let's do some fun math
path: scripts/pointing_stability.py
user: login: aberges-SLAC
body: That's a good point, but I also lament having function bloat for really simple array math like lines 218-219. Without just storing the data in some sort of separate class or object, I fear making a lot of bloated functions. 

Let me think harder and find a sophisticated solution
created_at: 2025-04-28T18:57:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,344 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import sys
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal lenght of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-r', '--near_field', action='store_true',
+                        default=False,
+                        help='Whether to perform calculations assuming near field imaging.')
+    parser.add_argument('-s', '--size', type=int,
+                        default=5000,
+                        help='Total number of centroids to acquire before calculating'
+                        ' the pointing stability. Defaults to 5000 counts')
+    return parser
+
+
+def build_data(data: list, value, **kw):
+    """Simple callback for appending camonitor output to a list"""
+    data.append(value)
+
+
+def print_progress_bar(iteration: int, total: int,
+                       prefix: str = '', suffix: str = '', decimals: int = 1,
+                       length: int = 100, fill: str = '\u2588',
+                       end: str = "\r") -> None:
+    """
+    Print the progressbar to terminal
+    Parameters
+    -----------
+    iteration (int):
+        iterator
+    total (int):
+        total number of iterations
+    prefix (str, optional):
+        Prefix to progress bar. Defaults to ''.
+    suffix (str, optional):
+        Suffix to progress bar. Defaults to ''.
+    decimals (int, optional):
+        Number of decimals to display in percentage. Defaults to 1.
+    length (int, optional):
+        Length of the progressbar. Defaults to 100.
+    fill (str, optional):
+        Progressbar fill symbol. Defaults to '\u2588' █.
+    end (str, optional):
+        Progressbar terminator. Defaults to "\r".
+    """
+
+    percent = ('{0:.' + f'{decimals}' + 'f}').format(100 * (iteration / float(total)))
+    filledLength = int(length * iteration // total)
+    bar = fill * filledLength + '-' * (length - filledLength)
+    color = Fore.LIGHTYELLOW_EX if round(filledLength / length, 1) < 1 else Fore.GREEN
+
+    print(color + f'\r{prefix} |{bar}| {percent}% {suffix}' + Style.RESET_ALL, end=end)
+
+
+def connect_centroids(cam_prefix: str) -> list[PV]:
+    """
+    Connect to the centroid PVs and wait for the PV objects to connect.
+    Parameters
+    -----------
+    cam_prefix (str):
+        Camera PV prefix.
+    Returns
+    -----------
+    list[epics.PV]:
+        list of the PV objects
+    """
+    centroid_x = PV(f'{cam_prefix}Stats2:CentroidX_RBV', connection_timeout=5)
+    centroid_y = PV(f'{cam_prefix}Stats2:CentroidY_RBV', connection_timeout=5)
+
+    centroid_x.wait_for_connection()
+    centroid_y.wait_for_connection()
+
+    return centroid_x, centroid_y
+
+
+def main():
+    # Build parser and initialize lists
+    parser = build_parser()
+    args = parser.parse_args()
+
+    # Make sure the prefix matches AD standard
+    camera = args.cam + ':' if args.cam[-1] != ':' else args.cam
+    model = PV(f'{camera}Model_RBV', connection_timeout=5).get()
+    if not model:
+        raise Exception(f'Could not connect to {camera}Model_RBV.')
+    # Make sure you're capturing if the bin is != 1
+    # First check the source of the stats plugin
+    stats_input_port = PV(f'{camera}Stats2:NDArrayPort', connection_timeout=5).get()
+    port = camera if 'CAM' in stats_input_port else f'{camera}{stats_input_port}:'
+    bin_x = PV(f'{port}BinX_RBV', connection_timeout=5).get()
+    bin_y = PV(f'{port}BinX_RBV', connection_timeout=5).get()
+    # Initialize lists and connect to centroid PVs
+    total = 5000
+    if args.size:
+        total = args.size
+    x_samples = []
+    y_samples = []
+
+    centroid_x_pv, centroid_y_pv = connect_centroids(camera)
+
+    # Collect data
+    centroid_x_pv.add_callback(partial(build_data, x_samples))
+    centroid_y_pv.add_callback(partial(build_data, y_samples))
+
+    while min(len(x_samples), len(y_samples)) < total:
+        if not args.quiet:
+            print_progress_bar(min(len(x_samples), len(y_samples)) + 1,
+                               total=total,
+                               prefix='Collecting data...', fill="\u2588")
+        time.sleep(0.1)
+
+    # Remove callback after collection
+    centroid_x_pv.remove_callback(1)
+    centroid_y_pv.remove_callback(1)
+
+    # Convert focal length to micron
+    focal_length = 10.0*1E4
+    if args.focal_length:
+        focal_length = args.focal_length*1E4
+
+    # Trim to same array lengths if necessary
+    if len(x_samples) != len(y_samples):
+        _length = min(len(x_samples), len(y_samples))
+        x_samples = x_samples[:_length]
+        y_samples = y_samples[:_length]
+
+    # Convert from pixels to real dimensions using vendor's data and account for binning
+    if args.pixel_size:
+        pixel_size = args.pixel_size
+    else:
+        pixel_size = PIXEL_DICT[model]
+    x_samples_um = np.asarray(x_samples)*pixel_size*bin_x
+    y_samples_um = np.asarray(y_samples)*pixel_size*bin_y
+
+    # Now let's do some fun math
path: scripts/pointing_stability.py
user: login: tangkong
body: what do you mean by "function bloat"?  I simply see us making functions that take in information (that you gather from argparse), and organize the logic into reusable parts.  Functions can be thought of as an organizational tool, gathering code into coherent thoughts.  

Nothing here justifies making additional classes/objects, you should be able to get by just passing data between functions.

These two lines may not deserve their own function, but the logic that uses their output should (read: all your plot creation)
created_at: 2025-04-28T20:24:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,344 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import sys
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal lenght of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-r', '--near_field', action='store_true',
+                        default=False,
+                        help='Whether to perform calculations assuming near field imaging.')
+    parser.add_argument('-s', '--size', type=int,
+                        default=5000,
+                        help='Total number of centroids to acquire before calculating'
+                        ' the pointing stability. Defaults to 5000 counts')
+    return parser
+
+
+def build_data(data: list, value, **kw):
+    """Simple callback for appending camonitor output to a list"""
+    data.append(value)
+
+
+def print_progress_bar(iteration: int, total: int,
+                       prefix: str = '', suffix: str = '', decimals: int = 1,
+                       length: int = 100, fill: str = '\u2588',
+                       end: str = "\r") -> None:
+    """
+    Print the progressbar to terminal
+    Parameters
+    -----------
+    iteration (int):
+        iterator
+    total (int):
+        total number of iterations
+    prefix (str, optional):
+        Prefix to progress bar. Defaults to ''.
+    suffix (str, optional):
+        Suffix to progress bar. Defaults to ''.
+    decimals (int, optional):
+        Number of decimals to display in percentage. Defaults to 1.
+    length (int, optional):
+        Length of the progressbar. Defaults to 100.
+    fill (str, optional):
+        Progressbar fill symbol. Defaults to '\u2588' █.
+    end (str, optional):
+        Progressbar terminator. Defaults to "\r".
+    """
+
+    percent = ('{0:.' + f'{decimals}' + 'f}').format(100 * (iteration / float(total)))
+    filledLength = int(length * iteration // total)
+    bar = fill * filledLength + '-' * (length - filledLength)
+    color = Fore.LIGHTYELLOW_EX if round(filledLength / length, 1) < 1 else Fore.GREEN
+
+    print(color + f'\r{prefix} |{bar}| {percent}% {suffix}' + Style.RESET_ALL, end=end)
+
+
+def connect_centroids(cam_prefix: str) -> list[PV]:
+    """
+    Connect to the centroid PVs and wait for the PV objects to connect.
+    Parameters
+    -----------
+    cam_prefix (str):
+        Camera PV prefix.
+    Returns
+    -----------
+    list[epics.PV]:
+        list of the PV objects
+    """
+    centroid_x = PV(f'{cam_prefix}Stats2:CentroidX_RBV', connection_timeout=5)
+    centroid_y = PV(f'{cam_prefix}Stats2:CentroidY_RBV', connection_timeout=5)
+
+    centroid_x.wait_for_connection()
+    centroid_y.wait_for_connection()
+
+    return centroid_x, centroid_y
+
+
+def main():
+    # Build parser and initialize lists
+    parser = build_parser()
+    args = parser.parse_args()
+
+    # Make sure the prefix matches AD standard
+    camera = args.cam + ':' if args.cam[-1] != ':' else args.cam
+    model = PV(f'{camera}Model_RBV', connection_timeout=5).get()
+    if not model:
+        raise Exception(f'Could not connect to {camera}Model_RBV.')
+    # Make sure you're capturing if the bin is != 1
+    # First check the source of the stats plugin
+    stats_input_port = PV(f'{camera}Stats2:NDArrayPort', connection_timeout=5).get()
+    port = camera if 'CAM' in stats_input_port else f'{camera}{stats_input_port}:'
+    bin_x = PV(f'{port}BinX_RBV', connection_timeout=5).get()
+    bin_y = PV(f'{port}BinX_RBV', connection_timeout=5).get()
+    # Initialize lists and connect to centroid PVs
+    total = 5000
+    if args.size:
+        total = args.size
+    x_samples = []
+    y_samples = []
+
+    centroid_x_pv, centroid_y_pv = connect_centroids(camera)
+
+    # Collect data
+    centroid_x_pv.add_callback(partial(build_data, x_samples))
+    centroid_y_pv.add_callback(partial(build_data, y_samples))
+
+    while min(len(x_samples), len(y_samples)) < total:
+        if not args.quiet:
+            print_progress_bar(min(len(x_samples), len(y_samples)) + 1,
+                               total=total,
+                               prefix='Collecting data...', fill="\u2588")
+        time.sleep(0.1)
+
+    # Remove callback after collection
+    centroid_x_pv.remove_callback(1)
+    centroid_y_pv.remove_callback(1)
+
+    # Convert focal length to micron
+    focal_length = 10.0*1E4
+    if args.focal_length:
+        focal_length = args.focal_length*1E4
+
+    # Trim to same array lengths if necessary
+    if len(x_samples) != len(y_samples):
+        _length = min(len(x_samples), len(y_samples))
+        x_samples = x_samples[:_length]
+        y_samples = y_samples[:_length]
+
+    # Convert from pixels to real dimensions using vendor's data and account for binning
+    if args.pixel_size:
+        pixel_size = args.pixel_size
+    else:
+        pixel_size = PIXEL_DICT[model]
+    x_samples_um = np.asarray(x_samples)*pixel_size*bin_x
+    y_samples_um = np.asarray(y_samples)*pixel_size*bin_y
+
+    # Now let's do some fun math
path: scripts/pointing_stability.py
user: login: aberges-SLAC
body: That's fair, I think I'm being a hater about having functions for a lot of the code,  but the maintainability is worth more than the cost of elongating the file by a few lines to document/implement the functions.

I definitely agree the plotting needs a subfunction, I will get to refactoring that right now.

Then I will rebase and (hopefully) not botch the job
created_at: 2025-04-28T21:20:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,344 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import sys
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal lenght of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-r', '--near_field', action='store_true',
+                        default=False,
+                        help='Whether to perform calculations assuming near field imaging.')
+    parser.add_argument('-s', '--size', type=int,
+                        default=5000,
+                        help='Total number of centroids to acquire before calculating'
+                        ' the pointing stability. Defaults to 5000 counts')
+    return parser
+
+
+def build_data(data: list, value, **kw):
+    """Simple callback for appending camonitor output to a list"""
+    data.append(value)
+
+
+def print_progress_bar(iteration: int, total: int,
+                       prefix: str = '', suffix: str = '', decimals: int = 1,
+                       length: int = 100, fill: str = '\u2588',
+                       end: str = "\r") -> None:
+    """
+    Print the progressbar to terminal
+    Parameters
+    -----------
+    iteration (int):
+        iterator
+    total (int):
+        total number of iterations
+    prefix (str, optional):
+        Prefix to progress bar. Defaults to ''.
+    suffix (str, optional):
+        Suffix to progress bar. Defaults to ''.
+    decimals (int, optional):
+        Number of decimals to display in percentage. Defaults to 1.
+    length (int, optional):
+        Length of the progressbar. Defaults to 100.
+    fill (str, optional):
+        Progressbar fill symbol. Defaults to '\u2588' █.
+    end (str, optional):
+        Progressbar terminator. Defaults to "\r".
+    """
+
+    percent = ('{0:.' + f'{decimals}' + 'f}').format(100 * (iteration / float(total)))
+    filledLength = int(length * iteration // total)
+    bar = fill * filledLength + '-' * (length - filledLength)
+    color = Fore.LIGHTYELLOW_EX if round(filledLength / length, 1) < 1 else Fore.GREEN
+
+    print(color + f'\r{prefix} |{bar}| {percent}% {suffix}' + Style.RESET_ALL, end=end)
+
+
+def connect_centroids(cam_prefix: str) -> list[PV]:
+    """
+    Connect to the centroid PVs and wait for the PV objects to connect.
+    Parameters
+    -----------
+    cam_prefix (str):
+        Camera PV prefix.
+    Returns
+    -----------
+    list[epics.PV]:
+        list of the PV objects
+    """
+    centroid_x = PV(f'{cam_prefix}Stats2:CentroidX_RBV', connection_timeout=5)
+    centroid_y = PV(f'{cam_prefix}Stats2:CentroidY_RBV', connection_timeout=5)
+
+    centroid_x.wait_for_connection()
+    centroid_y.wait_for_connection()
+
+    return centroid_x, centroid_y
+
+
+def main():
+    # Build parser and initialize lists
+    parser = build_parser()
+    args = parser.parse_args()
+
+    # Make sure the prefix matches AD standard
+    camera = args.cam + ':' if args.cam[-1] != ':' else args.cam
+    model = PV(f'{camera}Model_RBV', connection_timeout=5).get()
+    if not model:
+        raise Exception(f'Could not connect to {camera}Model_RBV.')
+    # Make sure you're capturing if the bin is != 1
+    # First check the source of the stats plugin
+    stats_input_port = PV(f'{camera}Stats2:NDArrayPort', connection_timeout=5).get()
+    port = camera if 'CAM' in stats_input_port else f'{camera}{stats_input_port}:'
+    bin_x = PV(f'{port}BinX_RBV', connection_timeout=5).get()
+    bin_y = PV(f'{port}BinX_RBV', connection_timeout=5).get()
path: scripts/pointing_stability.py
user: login: aberges-SLAC
body: Good catch! Getting that fixed right now. Also caught my typo
created_at: 2025-04-28T21:22:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,344 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import sys
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal lenght of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-r', '--near_field', action='store_true',
+                        default=False,
+                        help='Whether to perform calculations assuming near field imaging.')
+    parser.add_argument('-s', '--size', type=int,
+                        default=5000,
+                        help='Total number of centroids to acquire before calculating'
+                        ' the pointing stability. Defaults to 5000 counts')
+    return parser
+
+
+def build_data(data: list, value, **kw):
+    """Simple callback for appending camonitor output to a list"""
+    data.append(value)
+
+
+def print_progress_bar(iteration: int, total: int,
path: scripts/pointing_stability.py
user: login: aberges-SLAC
body: I like this! Wish I didn't reinvent the wheel, but also unsure if I can use a safe iterable for my `while` loop that I'm using as a `rest` between `camonitor` events.
created_at: 2025-04-28T23:37:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,433 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV, caget
+from matplotlib.figure import Figure
+from numpy.typing import ArrayLike, NDArray
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
path: scripts/pointing_stability.py
user: login: tangkong
body: A comment or more descriptive name might be good here.  Is this a pixel size mapping?  Maybe `CAM_TO_PIXEL_SIZE`?
created_at: 2025-05-01T17:01:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,344 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import sys
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal lenght of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-r', '--near_field', action='store_true',
+                        default=False,
+                        help='Whether to perform calculations assuming near field imaging.')
+    parser.add_argument('-s', '--size', type=int,
+                        default=5000,
+                        help='Total number of centroids to acquire before calculating'
+                        ' the pointing stability. Defaults to 5000 counts')
+    return parser
+
+
+def build_data(data: list, value, **kw):
+    """Simple callback for appending camonitor output to a list"""
+    data.append(value)
+
+
+def print_progress_bar(iteration: int, total: int,
+                       prefix: str = '', suffix: str = '', decimals: int = 1,
+                       length: int = 100, fill: str = '\u2588',
+                       end: str = "\r") -> None:
+    """
+    Print the progressbar to terminal
+    Parameters
+    -----------
+    iteration (int):
+        iterator
+    total (int):
+        total number of iterations
+    prefix (str, optional):
+        Prefix to progress bar. Defaults to ''.
+    suffix (str, optional):
+        Suffix to progress bar. Defaults to ''.
+    decimals (int, optional):
+        Number of decimals to display in percentage. Defaults to 1.
+    length (int, optional):
+        Length of the progressbar. Defaults to 100.
+    fill (str, optional):
+        Progressbar fill symbol. Defaults to '\u2588' █.
+    end (str, optional):
+        Progressbar terminator. Defaults to "\r".
+    """
+
+    percent = ('{0:.' + f'{decimals}' + 'f}').format(100 * (iteration / float(total)))
+    filledLength = int(length * iteration // total)
+    bar = fill * filledLength + '-' * (length - filledLength)
+    color = Fore.LIGHTYELLOW_EX if round(filledLength / length, 1) < 1 else Fore.GREEN
+
+    print(color + f'\r{prefix} |{bar}| {percent}% {suffix}' + Style.RESET_ALL, end=end)
+
+
+def connect_centroids(cam_prefix: str) -> list[PV]:
+    """
+    Connect to the centroid PVs and wait for the PV objects to connect.
+    Parameters
+    -----------
+    cam_prefix (str):
+        Camera PV prefix.
+    Returns
+    -----------
+    list[epics.PV]:
+        list of the PV objects
+    """
+    centroid_x = PV(f'{cam_prefix}Stats2:CentroidX_RBV', connection_timeout=5)
+    centroid_y = PV(f'{cam_prefix}Stats2:CentroidY_RBV', connection_timeout=5)
+
+    centroid_x.wait_for_connection()
path: scripts/pointing_stability.py
user: login: tangkong
body: Bumping this comment
created_at: 2025-05-01T17:04:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,433 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV, caget
+from matplotlib.figure import Figure
+from numpy.typing import ArrayLike, NDArray
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal length of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD.')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.'
+                        ' Defaults to "data_" appended by the camera name and timestamp.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-r', '--near_field', action='store_true',
+                        default=False,
+                        help='Whether to perform calculations assuming near field imaging.')
+    parser.add_argument('-s', '--size', type=int,
+                        default=5000,
+                        help='Total number of centroids to acquire before calculating'
+                        ' the pointing stability. Defaults to 5000 counts')
+    return parser
+
+
+def build_data(data: list, value, **kw):
+    """Simple callback for appending camonitor output to a list"""
+    data.append(value)
+
+
+def print_progress_bar(iteration: int, total: int,
+                       prefix: str = '', suffix: str = '', decimals: int = 1,
+                       length: int = 100, fill: str = '\u2588',
+                       end: str = "\r") -> None:
+    """
+    Print the progressbar to terminal
+    Parameters
+    -----------
+    iteration (int):
+        iterator
+    total (int):
+        total number of iterations
+    prefix (str, optional):
+        Prefix to progress bar. Defaults to ''.
+    suffix (str, optional):
+        Suffix to progress bar. Defaults to ''.
+    decimals (int, optional):
+        Number of decimals to display in percentage. Defaults to 1.
+    length (int, optional):
+        Length of the progressbar. Defaults to 100.
+    fill (str, optional):
+        Progressbar fill symbol. Defaults to '\u2588' █.
+    end (str, optional):
+        Progressbar terminator. Defaults to "\r".
+    """
+
+    percent = ('{0:.' + f'{decimals}' + 'f}').format(100 * (iteration / float(total)))
+    filledLength = int(length * iteration // total)
+    bar = fill * filledLength + '-' * (length - filledLength)
+    color = Fore.LIGHTYELLOW_EX if round(filledLength / length, 1) < 1 else Fore.GREEN
+
+    print(color + f'\r{prefix} |{bar}| {percent}% {suffix}' + Style.RESET_ALL, end=end)
+
+
+def connect_centroids(cam_prefix: str) -> list[PV]:
+    """
+    Connect to the centroid PVs and wait for the PV objects to connect.
+    Parameters
+    -----------
+    cam_prefix (str):
+        Camera PV prefix.
+    Returns
+    -----------
+    list[epics.PV]:
+        list of the PV objects
+    """
+    centroid_x = PV(f'{cam_prefix}Stats2:CentroidX_RBV', connection_timeout=5)
+    centroid_y = PV(f'{cam_prefix}Stats2:CentroidY_RBV', connection_timeout=5)
+
+    centroid_x.wait_for_connection()
+    centroid_y.wait_for_connection()
+
+    return centroid_x, centroid_y
+
+
+def convert_px_to_um(data: ArrayLike, pixel_size: float, bin: int) -> NDArray:
+    """
+    Convert list of centroid data from pixels to micron.
+    Returns NDArray.
+    """
+    return np.asarray(data)*pixel_size*bin
+
+
+def center_dataset(data: NDArray) -> NDArray:
+    """
+    Convert dataset to displacement from the mean.
+    Returns NDArray.
+    """
+    mean = np.mean(data)
+    return data - mean
+
+
+def calc_pos_stability(centered_data: NDArray) -> float:
+    """
+    Calculate the positional stability using statistics in the centered array data.
+    Returns float.
+    """
+    return 4*np.std(centered_data, ddof=1)
+
+
+def calc_angular_displacement(centered_data: NDArray,
+                              focal_length: float) -> list[NDArray, float, float]:
+    """
+    For far field calculations, convert the positional displacements to angular ones.
+    Parameters
+    -----------
+    centered_data (NDArray): centroid data represented as displacement from the mean
+    focal_length (float): focal length of the lens in micron.
+
+    Returns
+    --------
+    list[NDArray, float, float]: theta, theta_avg, theta_std
+        theta is an NDArray in microradians
+        theta_avg is the mean of the theta array
+        theta_std is the standard deviation (N-1)
+    """
+    theta = np.arctan(centered_data/focal_length)*1E6
+    theta_avg = np.mean(theta)
+    theta_std = np.std(theta, ddof=1)
+    return [theta, theta_avg, theta_std]
+
+
+def make_figures(position_data: list[NDArray],
+                 pos_stabilities: list[float],
+                 camera: str, num_plots: int = 3, quiet: bool = False,
+                 theta_data: list[NDArray] = None,
+                 theta_stats: list[list[float]] = None
+                 ) -> Figure:
+    """
+    Generate the figures for the pointing stability output. Optionally
+    include the angular data. Returns a matplotlib Figure.
+
+    Parameters
+    -----------
+    position_data (list[NDArray]):
+        List of the centered position data. e.g. [x_centered, y_centered]
+    pos_stabilities (list[float]):
+        List of positional stabilities. e.g. [x_stability, y_stability]
+    camera (str):
+        Camera PV prefix
+    num_plots (int, optional):
+        Number of plots to use. Defaults to 3.
+    quiet (bool, optional):
+        Surpress printing the graphic. Defaults to False.
+    theta_data (list[NDArray], optional):
+        Angular data, typically in the form of [theta_x, theta_y]. Defaults to None.
+    theta_stats (list[list[float]], optional):
+        Statistics for the angular data.
+        e.g. [[theta_x_avg, theta_x_std], [theta_y_avg, theta_y_std]]
path: scripts/pointing_stability.py
user: login: tangkong
body: Nit: I'd be wary of bundling pieces of data into lists like this.  If you know the pieces of data you're expecting, why not just supply them as individual arguments and be clear about what is what?  It's not like the number of derived theta statistics is changing.

I'm leaving this as a nit for now, but in other contexts I might be more of a stickler for this.
created_at: 2025-05-01T17:10:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,433 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV, caget
+from matplotlib.figure import Figure
+from numpy.typing import ArrayLike, NDArray
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal length of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD.')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.'
+                        ' Defaults to "data_" appended by the camera name and timestamp.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-r', '--near_field', action='store_true',
+                        default=False,
+                        help='Whether to perform calculations assuming near field imaging.')
+    parser.add_argument('-s', '--size', type=int,
+                        default=5000,
+                        help='Total number of centroids to acquire before calculating'
+                        ' the pointing stability. Defaults to 5000 counts')
+    return parser
+
+
+def build_data(data: list, value, **kw):
+    """Simple callback for appending camonitor output to a list"""
+    data.append(value)
+
+
+def print_progress_bar(iteration: int, total: int,
+                       prefix: str = '', suffix: str = '', decimals: int = 1,
+                       length: int = 100, fill: str = '\u2588',
+                       end: str = "\r") -> None:
+    """
+    Print the progressbar to terminal
+    Parameters
+    -----------
+    iteration (int):
+        iterator
+    total (int):
+        total number of iterations
+    prefix (str, optional):
+        Prefix to progress bar. Defaults to ''.
+    suffix (str, optional):
+        Suffix to progress bar. Defaults to ''.
+    decimals (int, optional):
+        Number of decimals to display in percentage. Defaults to 1.
+    length (int, optional):
+        Length of the progressbar. Defaults to 100.
+    fill (str, optional):
+        Progressbar fill symbol. Defaults to '\u2588' █.
+    end (str, optional):
+        Progressbar terminator. Defaults to "\r".
+    """
+
+    percent = ('{0:.' + f'{decimals}' + 'f}').format(100 * (iteration / float(total)))
+    filledLength = int(length * iteration // total)
+    bar = fill * filledLength + '-' * (length - filledLength)
+    color = Fore.LIGHTYELLOW_EX if round(filledLength / length, 1) < 1 else Fore.GREEN
+
+    print(color + f'\r{prefix} |{bar}| {percent}% {suffix}' + Style.RESET_ALL, end=end)
+
+
+def connect_centroids(cam_prefix: str) -> list[PV]:
+    """
+    Connect to the centroid PVs and wait for the PV objects to connect.
+    Parameters
+    -----------
+    cam_prefix (str):
+        Camera PV prefix.
+    Returns
+    -----------
+    list[epics.PV]:
+        list of the PV objects
+    """
+    centroid_x = PV(f'{cam_prefix}Stats2:CentroidX_RBV', connection_timeout=5)
+    centroid_y = PV(f'{cam_prefix}Stats2:CentroidY_RBV', connection_timeout=5)
+
+    centroid_x.wait_for_connection()
+    centroid_y.wait_for_connection()
+
+    return centroid_x, centroid_y
+
+
+def convert_px_to_um(data: ArrayLike, pixel_size: float, bin: int) -> NDArray:
+    """
+    Convert list of centroid data from pixels to micron.
+    Returns NDArray.
+    """
+    return np.asarray(data)*pixel_size*bin
+
+
+def center_dataset(data: NDArray) -> NDArray:
+    """
+    Convert dataset to displacement from the mean.
+    Returns NDArray.
+    """
+    mean = np.mean(data)
+    return data - mean
+
+
+def calc_pos_stability(centered_data: NDArray) -> float:
+    """
+    Calculate the positional stability using statistics in the centered array data.
+    Returns float.
+    """
+    return 4*np.std(centered_data, ddof=1)
+
+
+def calc_angular_displacement(centered_data: NDArray,
+                              focal_length: float) -> list[NDArray, float, float]:
+    """
+    For far field calculations, convert the positional displacements to angular ones.
+    Parameters
+    -----------
+    centered_data (NDArray): centroid data represented as displacement from the mean
+    focal_length (float): focal length of the lens in micron.
+
+    Returns
+    --------
+    list[NDArray, float, float]: theta, theta_avg, theta_std
+        theta is an NDArray in microradians
+        theta_avg is the mean of the theta array
+        theta_std is the standard deviation (N-1)
+    """
+    theta = np.arctan(centered_data/focal_length)*1E6
+    theta_avg = np.mean(theta)
+    theta_std = np.std(theta, ddof=1)
+    return [theta, theta_avg, theta_std]
+
+
+def make_figures(position_data: list[NDArray],
+                 pos_stabilities: list[float],
+                 camera: str, num_plots: int = 3, quiet: bool = False,
+                 theta_data: list[NDArray] = None,
+                 theta_stats: list[list[float]] = None
path: scripts/pointing_stability.py
user: login: tangkong
body: ```suggestion
                 theta_data: Optional[list[NDArray]] = None,
                 theta_stats: Optional[list[list[float]]] = None
```
created_at: 2025-05-01T17:11:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,433 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV, caget
+from matplotlib.figure import Figure
+from numpy.typing import ArrayLike, NDArray
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal length of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD.')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.'
+                        ' Defaults to "data_" appended by the camera name and timestamp.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-r', '--near_field', action='store_true',
+                        default=False,
+                        help='Whether to perform calculations assuming near field imaging.')
+    parser.add_argument('-s', '--size', type=int,
+                        default=5000,
+                        help='Total number of centroids to acquire before calculating'
+                        ' the pointing stability. Defaults to 5000 counts')
+    return parser
+
+
+def build_data(data: list, value, **kw):
+    """Simple callback for appending camonitor output to a list"""
+    data.append(value)
+
+
+def print_progress_bar(iteration: int, total: int,
+                       prefix: str = '', suffix: str = '', decimals: int = 1,
+                       length: int = 100, fill: str = '\u2588',
+                       end: str = "\r") -> None:
+    """
+    Print the progressbar to terminal
+    Parameters
+    -----------
+    iteration (int):
+        iterator
+    total (int):
+        total number of iterations
+    prefix (str, optional):
+        Prefix to progress bar. Defaults to ''.
+    suffix (str, optional):
+        Suffix to progress bar. Defaults to ''.
+    decimals (int, optional):
+        Number of decimals to display in percentage. Defaults to 1.
+    length (int, optional):
+        Length of the progressbar. Defaults to 100.
+    fill (str, optional):
+        Progressbar fill symbol. Defaults to '\u2588' █.
+    end (str, optional):
+        Progressbar terminator. Defaults to "\r".
+    """
+
+    percent = ('{0:.' + f'{decimals}' + 'f}').format(100 * (iteration / float(total)))
+    filledLength = int(length * iteration // total)
+    bar = fill * filledLength + '-' * (length - filledLength)
+    color = Fore.LIGHTYELLOW_EX if round(filledLength / length, 1) < 1 else Fore.GREEN
+
+    print(color + f'\r{prefix} |{bar}| {percent}% {suffix}' + Style.RESET_ALL, end=end)
+
+
+def connect_centroids(cam_prefix: str) -> list[PV]:
+    """
+    Connect to the centroid PVs and wait for the PV objects to connect.
+    Parameters
+    -----------
+    cam_prefix (str):
+        Camera PV prefix.
+    Returns
+    -----------
+    list[epics.PV]:
+        list of the PV objects
+    """
+    centroid_x = PV(f'{cam_prefix}Stats2:CentroidX_RBV', connection_timeout=5)
+    centroid_y = PV(f'{cam_prefix}Stats2:CentroidY_RBV', connection_timeout=5)
+
+    centroid_x.wait_for_connection()
+    centroid_y.wait_for_connection()
+
+    return centroid_x, centroid_y
+
+
+def convert_px_to_um(data: ArrayLike, pixel_size: float, bin: int) -> NDArray:
+    """
+    Convert list of centroid data from pixels to micron.
+    Returns NDArray.
+    """
+    return np.asarray(data)*pixel_size*bin
+
+
+def center_dataset(data: NDArray) -> NDArray:
+    """
+    Convert dataset to displacement from the mean.
+    Returns NDArray.
+    """
+    mean = np.mean(data)
+    return data - mean
+
+
+def calc_pos_stability(centered_data: NDArray) -> float:
+    """
+    Calculate the positional stability using statistics in the centered array data.
+    Returns float.
+    """
+    return 4*np.std(centered_data, ddof=1)
+
+
+def calc_angular_displacement(centered_data: NDArray,
+                              focal_length: float) -> list[NDArray, float, float]:
+    """
+    For far field calculations, convert the positional displacements to angular ones.
+    Parameters
+    -----------
+    centered_data (NDArray): centroid data represented as displacement from the mean
+    focal_length (float): focal length of the lens in micron.
+
+    Returns
+    --------
+    list[NDArray, float, float]: theta, theta_avg, theta_std
+        theta is an NDArray in microradians
+        theta_avg is the mean of the theta array
+        theta_std is the standard deviation (N-1)
+    """
+    theta = np.arctan(centered_data/focal_length)*1E6
+    theta_avg = np.mean(theta)
+    theta_std = np.std(theta, ddof=1)
+    return [theta, theta_avg, theta_std]
+
+
+def make_figures(position_data: list[NDArray],
+                 pos_stabilities: list[float],
+                 camera: str, num_plots: int = 3, quiet: bool = False,
+                 theta_data: list[NDArray] = None,
+                 theta_stats: list[list[float]] = None
+                 ) -> Figure:
+    """
+    Generate the figures for the pointing stability output. Optionally
+    include the angular data. Returns a matplotlib Figure.
+
+    Parameters
+    -----------
+    position_data (list[NDArray]):
+        List of the centered position data. e.g. [x_centered, y_centered]
+    pos_stabilities (list[float]):
+        List of positional stabilities. e.g. [x_stability, y_stability]
+    camera (str):
+        Camera PV prefix
+    num_plots (int, optional):
+        Number of plots to use. Defaults to 3.
+    quiet (bool, optional):
+        Surpress printing the graphic. Defaults to False.
+    theta_data (list[NDArray], optional):
+        Angular data, typically in the form of [theta_x, theta_y]. Defaults to None.
+    theta_stats (list[list[float]], optional):
+        Statistics for the angular data.
+        e.g. [[theta_x_avg, theta_x_std], [theta_y_avg, theta_y_std]]
+        Defaults to None.
+
+    Returns
+    --------
+        Figure: matplotlib Figure
+    """
+    if not theta_data:
+        num_plots = 2
+    fig, axs = plt.subplots(num_plots,
+                            height_ratios=(num_plots-1)*[1]+[0.5])
+    # Positional displacement plot
+    _positions = np.asarray(position_data)
+    _min = _positions.min()*1.05
+    _max = _positions.max()*1.05
+    axs[0].scatter(x=position_data[0], y=position_data[-1])
+    axs[0].axhline(y=0, color='black', linestyle='--')
+    axs[0].axvline(x=0, color='black', linestyle='--')
+    axs[0].set_xlim(_min, _max)
+    axs[0].set_ylim(_min, _max)
+    axs[0].set_xlabel('X displacement (\u03bcm)')
+    axs[0].set_ylabel('Y displacement (\u03bcm)')
+    axs[0].set_box_aspect(1)
+    # Stat data for table printing
+    col_labels = ['X', 'Y']
+    row_labels = ['\u0394 (\u03bcm)']
+    table_data = [pos_stabilities]
+    # Angular displacement subplot
+    if theta_data:
+        axs[1].hist(theta_data[0], bins='fd',
+                    fc=(0, 0, 1, 0.5),
+                    edgecolor='black',
+                    label='X')
+        axs[1].hist(theta_data[-1], bins='fd',
+                    fc=(1, 0.25, 0.25, 0.5),
+                    edgecolor='black',
+                    label='Y')
+        axs[1].legend(loc='upper right')
+        axs[1].set_xlabel("Pointing (\u03bcrad)")
+        axs[1].set_ylabel("Counts")
+        axs[1].set_box_aspect(1)
+        row_labels = row_labels + ['\u03b8 std_dev (\u03bcrad)']
+        table_data = table_data + [theta_stats]
+    # Now add the table
+    table_data = [[f'{_s:2.3e}' for _s in _ls] for _ls in table_data]
path: scripts/pointing_stability.py
user: login: tangkong
body: Even in list comprehensions we should have readable variable names
created_at: 2025-05-01T17:15:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,344 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import sys
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal lenght of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-r', '--near_field', action='store_true',
+                        default=False,
+                        help='Whether to perform calculations assuming near field imaging.')
+    parser.add_argument('-s', '--size', type=int,
+                        default=5000,
+                        help='Total number of centroids to acquire before calculating'
+                        ' the pointing stability. Defaults to 5000 counts')
+    return parser
+
+
+def build_data(data: list, value, **kw):
+    """Simple callback for appending camonitor output to a list"""
+    data.append(value)
+
+
+def print_progress_bar(iteration: int, total: int,
+                       prefix: str = '', suffix: str = '', decimals: int = 1,
+                       length: int = 100, fill: str = '\u2588',
+                       end: str = "\r") -> None:
+    """
+    Print the progressbar to terminal
+    Parameters
+    -----------
+    iteration (int):
+        iterator
+    total (int):
+        total number of iterations
+    prefix (str, optional):
+        Prefix to progress bar. Defaults to ''.
+    suffix (str, optional):
+        Suffix to progress bar. Defaults to ''.
+    decimals (int, optional):
+        Number of decimals to display in percentage. Defaults to 1.
+    length (int, optional):
+        Length of the progressbar. Defaults to 100.
+    fill (str, optional):
+        Progressbar fill symbol. Defaults to '\u2588' █.
+    end (str, optional):
+        Progressbar terminator. Defaults to "\r".
+    """
+
+    percent = ('{0:.' + f'{decimals}' + 'f}').format(100 * (iteration / float(total)))
+    filledLength = int(length * iteration // total)
+    bar = fill * filledLength + '-' * (length - filledLength)
+    color = Fore.LIGHTYELLOW_EX if round(filledLength / length, 1) < 1 else Fore.GREEN
+
+    print(color + f'\r{prefix} |{bar}| {percent}% {suffix}' + Style.RESET_ALL, end=end)
+
+
+def connect_centroids(cam_prefix: str) -> list[PV]:
+    """
+    Connect to the centroid PVs and wait for the PV objects to connect.
+    Parameters
+    -----------
+    cam_prefix (str):
+        Camera PV prefix.
+    Returns
+    -----------
+    list[epics.PV]:
+        list of the PV objects
+    """
+    centroid_x = PV(f'{cam_prefix}Stats2:CentroidX_RBV', connection_timeout=5)
+    centroid_y = PV(f'{cam_prefix}Stats2:CentroidY_RBV', connection_timeout=5)
+
+    centroid_x.wait_for_connection()
path: scripts/pointing_stability.py
user: login: aberges-SLAC
body: Thank you! Forget to push this commit
created_at: 2025-05-01T17:46:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,433 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV, caget
+from matplotlib.figure import Figure
+from numpy.typing import ArrayLike, NDArray
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal length of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD.')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.'
+                        ' Defaults to "data_" appended by the camera name and timestamp.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-r', '--near_field', action='store_true',
+                        default=False,
+                        help='Whether to perform calculations assuming near field imaging.')
+    parser.add_argument('-s', '--size', type=int,
+                        default=5000,
+                        help='Total number of centroids to acquire before calculating'
+                        ' the pointing stability. Defaults to 5000 counts')
+    return parser
+
+
+def build_data(data: list, value, **kw):
+    """Simple callback for appending camonitor output to a list"""
+    data.append(value)
+
+
+def print_progress_bar(iteration: int, total: int,
+                       prefix: str = '', suffix: str = '', decimals: int = 1,
+                       length: int = 100, fill: str = '\u2588',
+                       end: str = "\r") -> None:
+    """
+    Print the progressbar to terminal
+    Parameters
+    -----------
+    iteration (int):
+        iterator
+    total (int):
+        total number of iterations
+    prefix (str, optional):
+        Prefix to progress bar. Defaults to ''.
+    suffix (str, optional):
+        Suffix to progress bar. Defaults to ''.
+    decimals (int, optional):
+        Number of decimals to display in percentage. Defaults to 1.
+    length (int, optional):
+        Length of the progressbar. Defaults to 100.
+    fill (str, optional):
+        Progressbar fill symbol. Defaults to '\u2588' █.
+    end (str, optional):
+        Progressbar terminator. Defaults to "\r".
+    """
+
+    percent = ('{0:.' + f'{decimals}' + 'f}').format(100 * (iteration / float(total)))
+    filledLength = int(length * iteration // total)
+    bar = fill * filledLength + '-' * (length - filledLength)
+    color = Fore.LIGHTYELLOW_EX if round(filledLength / length, 1) < 1 else Fore.GREEN
+
+    print(color + f'\r{prefix} |{bar}| {percent}% {suffix}' + Style.RESET_ALL, end=end)
+
+
+def connect_centroids(cam_prefix: str) -> list[PV]:
+    """
+    Connect to the centroid PVs and wait for the PV objects to connect.
+    Parameters
+    -----------
+    cam_prefix (str):
+        Camera PV prefix.
+    Returns
+    -----------
+    list[epics.PV]:
+        list of the PV objects
+    """
+    centroid_x = PV(f'{cam_prefix}Stats2:CentroidX_RBV', connection_timeout=5)
+    centroid_y = PV(f'{cam_prefix}Stats2:CentroidY_RBV', connection_timeout=5)
+
+    centroid_x.wait_for_connection()
+    centroid_y.wait_for_connection()
+
+    return centroid_x, centroid_y
+
+
+def convert_px_to_um(data: ArrayLike, pixel_size: float, bin: int) -> NDArray:
+    """
+    Convert list of centroid data from pixels to micron.
+    Returns NDArray.
+    """
+    return np.asarray(data)*pixel_size*bin
+
+
+def center_dataset(data: NDArray) -> NDArray:
+    """
+    Convert dataset to displacement from the mean.
+    Returns NDArray.
+    """
+    mean = np.mean(data)
+    return data - mean
+
+
+def calc_pos_stability(centered_data: NDArray) -> float:
+    """
+    Calculate the positional stability using statistics in the centered array data.
+    Returns float.
+    """
+    return 4*np.std(centered_data, ddof=1)
+
+
+def calc_angular_displacement(centered_data: NDArray,
+                              focal_length: float) -> list[NDArray, float, float]:
+    """
+    For far field calculations, convert the positional displacements to angular ones.
+    Parameters
+    -----------
+    centered_data (NDArray): centroid data represented as displacement from the mean
+    focal_length (float): focal length of the lens in micron.
+
+    Returns
+    --------
+    list[NDArray, float, float]: theta, theta_avg, theta_std
+        theta is an NDArray in microradians
+        theta_avg is the mean of the theta array
+        theta_std is the standard deviation (N-1)
+    """
+    theta = np.arctan(centered_data/focal_length)*1E6
+    theta_avg = np.mean(theta)
+    theta_std = np.std(theta, ddof=1)
+    return [theta, theta_avg, theta_std]
+
+
+def make_figures(position_data: list[NDArray],
+                 pos_stabilities: list[float],
+                 camera: str, num_plots: int = 3, quiet: bool = False,
+                 theta_data: list[NDArray] = None,
+                 theta_stats: list[list[float]] = None
+                 ) -> Figure:
+    """
+    Generate the figures for the pointing stability output. Optionally
+    include the angular data. Returns a matplotlib Figure.
+
+    Parameters
+    -----------
+    position_data (list[NDArray]):
+        List of the centered position data. e.g. [x_centered, y_centered]
+    pos_stabilities (list[float]):
+        List of positional stabilities. e.g. [x_stability, y_stability]
+    camera (str):
+        Camera PV prefix
+    num_plots (int, optional):
+        Number of plots to use. Defaults to 3.
+    quiet (bool, optional):
+        Surpress printing the graphic. Defaults to False.
+    theta_data (list[NDArray], optional):
+        Angular data, typically in the form of [theta_x, theta_y]. Defaults to None.
+    theta_stats (list[list[float]], optional):
+        Statistics for the angular data.
+        e.g. [[theta_x_avg, theta_x_std], [theta_y_avg, theta_y_std]]
path: scripts/pointing_stability.py
user: login: aberges-SLAC
body: It's a fair nitpick for the positional data, I'll fix that.

For the `theta_data`, it was just so I only had to check once for `None`-ness. But I could just as easily do `any([e is None for e in list[theta_x, theta_y])`
created_at: 2025-05-01T17:55:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,344 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import sys
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
+              'Manta_G146B': 4.65,
+              'Manta_G146C': 4.65,
+              'Manta_G2460B': 2.74,
+              'acA1300-22gm': 3.75,
+              'acA1300-30gm': 3.75,
+              'acA1300-60gmNIR': 5.3,
+              'acA1440-73gm': 3.45,
+              'acA1600-60gm': 4.5,
+              'acA2500-20gm': 4.8,
+              'FLIR_A65': 17.0,
+              'FLIR_AX5': 50.0,
+              'JAI_AM-201GE': 5.5,
+              'PyrocamIIIHR': 80.0,
+              'Mako G-030B': 7.4,
+              'Mako G-032B': 7.4,
+              'Mako G-040B': 6.9,
+              'Mako G-125B': 3.75,
+              'Mako G-125C': 3.75,
+              'Mako G-158B': 3.45,
+              'Mako G-192B': 4.5,
+              'Mako G-234B': 5.86,
+              'Mako G-319B': 3.45,
+              'Mako G-319C': 3.45,
+              'Mako G-419B': 5.5,
+              'Manta G-033B': 9.9,
+              'Manta_G-046B': 8.3,
+              'Manta_G-125B': 3.75,
+              'Manta_G-145B': 6.45,
+              'Manta_G-145C': 6.45,
+              'Manta_G-146B': 4.65,
+              'Manta_G-146C': 4.65,
+              'Manta_G-158B': 3.45,
+              'Manta_G-201B': 4.4,
+              'Manta G-2460B': 2.74,
+              'Manta_G-282B': 3.69,
+              'Manta G-419B': 5.5,
+              'Manta G-419B-NIR': 5.5,
+              'Manta_G-419C': 5.5,
+              'Manta_G-504B': 3.45,
+              'Manta_G-504C': 3.45,
+              'Manta_G-917B': 3.69,
+              'Manta G-917C': 3.69,
+              'ProsilicaGC1350C': 4.65}
+
+
+def build_parser():
+    """
+    Constructs the parser for the script.
+    Returns:
+        parser: argparse.ArgumentParser
+    """
+    parser = argparse.ArgumentParser(
+        prog='pointing_stability',
+        formatter_class=argparse.RawTextHelpFormatter,
+        description='Collect centroids from the camera then'
+        ' determine the positional stability using ISO 11145:2018'
+        ' Default is to use far-field calculations and a 10 cm focal length.',
+        epilog='For more information on subcommands, use: '
+               'pointing_stability --help')
+
+    parser.add_argument('cam', type=str,
+                        help='PV prefix for the camera using ECS naming convention')
+    parser.add_argument('-l', '--focal_length', type=float,
+                        default=10.,
+                        help='Focal lenght of the imaging lens in cm as a float. Defaults to 10 cm.')
+    parser.add_argument('-d', '--dir', type=str,
+                        help='Set directory for the output data. Defaults to $PWD')
+    parser.add_argument('-f', '--filename', type=str,
+                        help='Set the base filename for the output data.')
+    parser.add_argument('-p', '--pixel_size', type=float,
+                        help='Set the pixel size if you have a special snowflake camera.')
+    parser.add_argument('-q', '--quiet', action='store_true',
+                        default=False,
+                        help='Supress the progressbar and graphs from printing to terminal')
+    parser.add_argument('-r', '--near_field', action='store_true',
path: scripts/pointing_stability.py
user: login: aberges-SLAC
body: Ah, dang, I didn't see this sooner. `-n` was originally a different flag for some reason or another, but you're right it is much more sensible to use that
created_at: 2025-05-01T20:54:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,433 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV, caget
+from matplotlib.figure import Figure
+from numpy.typing import ArrayLike, NDArray
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
path: scripts/pointing_stability.py
user: login: aberges-SLAC
body: `CAMERA_PIXEL_DICT` maybe?
created_at: 2025-05-01T20:54:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,433 @@
+"""
+Ported from Matt Bain's working example in chemRIXS, generalized for the Basler gigecams and applied ISO
+positional & pointing stability standards.
+"""
+
+import argparse
+import os
+import time
+from functools import partial
+
+import matplotlib.pyplot as plt
+import numpy as np
+from colorama import Fore, Style
+from epics import PV, caget
+from matplotlib.figure import Figure
+from numpy.typing import ArrayLike, NDArray
+
+PIXEL_DICT = {'Manta_G046B': 8.3,
path: scripts/pointing_stability.py
user: login: tangkong
body: I never liked writing the type of a variable in its name much myself, but if you find it useful it's fine
created_at: 2025-05-01T21:08:58Z
author_association: CONTRIBUTOR

PR comments:
user: login: tangkong
created_at: 2025-04-26T00:05:12Z
body: The commit history here is a bit polluted.  You seem to have commits from your grep_more_ioc PR here as well.  Can you rebase this onto master instead of your other branch?
author_association: CONTRIBUTOR
user: login: tangkong
created_at: 2025-04-26T00:15:53Z
body: A bit of an existential question: does this belong in engineering tools?  Engineering tools does not have proper packaging and dependencies, and this script relies on some rather heavy dependencies (matplotlib).

I realize engineering tools is a easy place to toss scripts, but those scripts were usually either bash or pure-base-python.  I do worry about this repo accumulating too many unrelated utilities

This is just my opinion though, perhaps others feel more strongly about what belongs in this repo
author_association: CONTRIBUTOR
user: login: aberges-SLAC
created_at: 2025-04-26T00:41:59Z
body: If I'm sourcing `pcds_conda` anyway and fixed version at that, should the dependencies matter very much? I suppose for future maintenance it does matter quite a lot.

I opted for this repo due it being sourced by pretty much everyone in laser science and engineering, but perhaps it should live elsewhere. I'm really not opposed to putting it anywhere that makes sense, so long as it has a home and few pair of eyes to look it over.

I don't mind making a `mods/scripts` directory and housing it there, since it was originally intended as a MODS helper.
author_association: CONTRIBUTOR
user: login: KaushikMalapati
created_at: 2025-04-26T03:51:56Z
body: Do you think anyone outside of mods would use this?
author_association: CONTRIBUTOR
user: login: tangkong
created_at: 2025-04-28T16:32:16Z
body: > If I'm sourcing `pcds_conda` anyway and fixed version at that, should the dependencies matter very much? I suppose for future maintenance it does matter quite a lot.

There's a distant world where we'll be converting pcds_conda to a more minimal environment, so eventually pcds_conda may not be sufficient for every application by default.  (this is not something you should plan for, but it's something I keep in mind)

It's more about separation of concerns and better maintenance.  Engineering tools is bloated as it is, and I'm sort of loathe to continue adding scripts. But maybe that's just my opinion, and we actually want to shove everything into engineering_tools.

author_association: CONTRIBUTOR
user: login: aberges-SLAC
created_at: 2025-04-28T18:37:22Z
body: > Do you think anyone outside of mods would use this?

I could think of several applications in laser systems for using this script, as really the only requirement is that the gigecam has an IOC running at their test station. Although, I'm unsure if we have any commercial systems out there for testing & VNV.
author_association: CONTRIBUTOR
user: login: ZLLentz
created_at: 2025-04-28T22:30:18Z
body: We're definitely blurring the lines between "engineering_tools" and "science_tools" here. It's not necessarily a problem but it's food for thought.
author_association: MEMBER
user: login: aberges-SLAC
created_at: 2025-05-01T20:40:35Z
body: @tangkong @KaushikMalapati 
I have bundled your suggested commits into the one's I've documented and pushed. Code should be good to go now!
author_association: CONTRIBUTOR
user: login: aberges-SLAC
created_at: 2025-05-01T20:58:24Z
body: > Thanks for humoring my nitpicks!

I lied, I missed 2 nitpicks that I agree very much with. Last time I promise [;
author_association: CONTRIBUTOR