Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH: comparisons and "atef check" command-line tool #23

Merged
merged 37 commits into from
Feb 18, 2022

Conversation

klauer
Copy link
Contributor

@klauer klauer commented Jan 8, 2022

Bullet points:

  • Trying to flesh out what comparisons with severity + explanations could look like.
  • These need to be made serializable
  • Then they need to be related to ophyd object attr-to-value dictionaries somehow
  • Then the reduction functionality needs work (is it too late to add asyncio stuff here? maybe/maybe not?)
  • It's possible this could have just been a json schema validation step of sorts, but I have a feeling it will be more complicated going that route instead of reimplementing the wheel. It's not too late to go back if we decide differently, though.

@klauer
Copy link
Contributor Author

klauer commented Jan 10, 2022

HXR EBD/FEE checkout helper PVs mapped to happi item + attribute:

AT2L0:CALC:SYS:ActualTransmission_RBV -> at2l0.calculator.actual_transmission lastSample [None, 0.02, 1]
AT2L0:XTES:MMS:01:STATE:GET_RBV -> at2l0.blade_01.state.state default operator [None, 0, 1, 2]
AT2L0:XTES:MMS:02:STATE:GET_RBV -> at2l0.blade_02.state.state default operator [None, 0, 1, 2]
AT2L0:XTES:MMS:03:STATE:GET_RBV -> at2l0.blade_03.state.state default operator [None, 0, 1, 2]
AT2L0:XTES:MMS:04:STATE:GET_RBV -> at2l0.blade_04.state.state default operator [None, 0, 1, 2]
AT2L0:XTES:MMS:05:STATE:GET_RBV -> at2l0.blade_05.state.state default operator [None, 0, 1, 2]
AT2L0:XTES:MMS:06:STATE:GET_RBV -> at2l0.blade_06.state.state default operator [None, 0, 1, 2]
AT2L0:XTES:MMS:07:STATE:GET_RBV -> at2l0.blade_07.state.state default operator [None, 0, 1, 2]
AT2L0:XTES:MMS:08:STATE:GET_RBV -> at2l0.blade_08.state.state default operator [None, 0, 1, 2]
AT2L0:XTES:MMS:09:STATE:GET_RBV -> at2l0.blade_09.state.state default operator [None, 0, 1, 2]
AT2L0:XTES:MMS:10:STATE:GET_RBV -> at2l0.blade_10.state.state default operator [None, 0, 1, 2]
AT2L0:XTES:MMS:11:STATE:GET_RBV -> at2l0.blade_11.state.state default operator [None, 0, 1, 2]
AT2L0:XTES:MMS:12:STATE:GET_RBV -> at2l0.blade_12.state.state default operator [None, 0, 1, 2]
AT2L0:XTES:MMS:13:STATE:GET_RBV -> at2l0.blade_13.state.state default operator [None, 0, 1, 2]
AT2L0:XTES:MMS:14:STATE:GET_RBV -> at2l0.blade_14.state.state default operator [None, 0, 1, 2]
AT2L0:XTES:MMS:15:STATE:GET_RBV -> at2l0.blade_15.state.state default operator [None, 0, 1, 2]
AT2L0:XTES:MMS:16:STATE:GET_RBV -> at2l0.blade_16.state.state default operator [None, 0, 1, 2]
AT2L0:XTES:MMS:17:STATE:GET_RBV -> at2l0.blade_17.state.state default operator [None, 0, 1, 2]
AT2L0:XTES:MMS:18:STATE:GET_RBV -> at2l0.blade_18.state.state default operator [None, 0, 1, 2]
AT2L0:XTES:MMS:19:STATE:GET_RBV -> at2l0.blade_19.state.state default operator [None, 0, 1, 2]
BT2L0:PLEG:VGC:01:OPN_DI_RBV -> bt2l0_pleg_vgc01.open_limit lastSample [None, 1]
EM1L0:GEM:VGC:10:OPN_DI_RBV -> em1l0_gem_vgc10.open_limit default operator [None, 1]
EM2L0:GEM:VGC:70:OPN_DI_RBV -> em2l0_gem_vgc70.open_limit default operator [None, 1]
IM1K0:XTES:CAM:MaxSizeX_RBV -> im1k0.detector.sensor_size_x default operator [None, 512, 1024, 1025]
IM1K0:XTES:CAM:MaxSizeY_RBV -> im1k0.detector.sensor_size_y default operator [None, 512, 1024, 1025]
IM1K0:XTES:MFW:GET_RBV -> im1k0.filter_wheel.state default operator [None, 5, 6]
IM1L0:XTES:CLF.RBV -> im1l0.focus_motor default operator [None, 75, 77]
IM1L0:XTES:CLF:PLC:bHomeCmd_RBV -> im1l0.focus_motor.plc.cmd_home flyers None
IM1L0:XTES:CLZ.RBV -> im1l0.zoom_motor default operator [None, 28, 30]
IM1L0:XTES:MMS:STATE:GET_RBV -> im1l0.target.state default operator [None, 4]
IM1L0:XTES:VGC:01:OPN_DI_RBV -> im1l0_xtes_vgc01.open_limit default operator [None, 1]
MR1L0:HOMS:MMS:PITCH.RBV -> mr1l0_homs.pitch lastSample None
MR1L0:HOMS:MMS:PITCH.RBV -> mr1l0_homs.pitch mean [None, 3, 5.85, 5.89, 12.21, 12.25, 15]
MR1L0:HOMS:MMS:PITCH.RBV -> mr1l0_homs.pitch std [None, 80]
MR1L0:HOMS:MMS:XUP.RBV -> mr1l0_homs.x_up mean [None, 99, 101]
MR1L0:HOMS:MMS:YUP.RBV -> mr1l0_homs.y_up mean [None, -3800, 4900, 7000]
MR1L0:HOMS:MMS:YUP.RBV -> mr1l0_homs.y_up mean [None, 7000]
MR1L0:HOMS:VGC:01:OPN_DI_RBV -> mr1l0_homs_vgc01.open_limit default operator [None, 1]
MR2L0:HOMS:MMS:PITCH.RBV -> mr2l0_homs.pitch lastSample None
MR2L0:HOMS:MMS:PITCH.RBV -> mr2l0_homs.pitch lastSample [None, 17.5, 17.98, 18.09, 18.5, 20, 20.5, 20.6, 21]
MR2L0:HOMS:MMS:PITCH.RBV -> mr2l0_homs.pitch std [None, 80]
MR2L0:HOMS:MMS:XUP.RBV -> mr2l0_homs.x_up mean [None, -1, 0.5]
MR2L0:HOMS:MMS:YUP.RBV -> mr2l0_homs.y_up mean [None, -5005, -4995, 3950, 4050]
MR2L0:HOMS:MMS:YUP.RBV -> mr2l0_homs.y_up mean [None, 3900, 4100]
MR2L0:HOMS:VGC:01:OPN_DI_RBV -> mr2l0_homs_vgc01.open_limit default operator [None, 1]
MR2L0:HOMS:VGC:02:OPN_DI_RBV -> mr2l0_homs_vgc_02.open_limit default operator [None, 1]
PA1L0:VFS:01:POS_STATE_RBV -> pa1l0_vfs_01.valve_position default operator [None, 1]
PC1L0:XTES:VGC:01:OPN_DI_RBV -> pc1l0_xtes_vgc01.open_limit default operator [None, 1]
RTDSL0:MPA:01:IN_RBV -> rtdsl0.mpa1.in_sw default operator [None, 1]
RTDSL0:MPA:02:IN_RBV -> rtdsl0.mpa2.in_sw default operator [None, 1]
SATT:FEE1:321:STATE -> at1l0.filter1.state lastSample [None, 1, 2]
SATT:FEE1:322:STATE -> at1l0.filter2.state default operator [None, 1, 2]
SATT:FEE1:323:STATE -> at1l0.filter3.state default operator [None, 1, 2]
SATT:FEE1:324:STATE -> at1l0.filter4.state default operator [None, 1, 2]
SATT:FEE1:325:STATE -> at1l0.filter5.state default operator [None, 1, 2]
SATT:FEE1:326:STATE -> at1l0.filter6.state default operator [None, 1, 2]
SATT:FEE1:327:STATE -> at1l0.filter7.state default operator [None, 1, 2]
SATT:FEE1:328:STATE -> at1l0.filter8.state default operator [None, 1, 2]
SATT:FEE1:329:STATE -> at1l0.filter9.state default operator [None, 1, 2]
SL1L0:POWER:ACTUAL_XWIDTH_RBV -> sl1l0.xwidth.readback default operator [None, 2, 2.5, 3.5, 20]
SL1L0:POWER:ACTUAL_YWIDTH_RBV -> sl1l0.ywidth.readback default operator [None, 2, 2.5, 3.5, 20]
SP1L0:KMONO:MMS:DIODE_VERT.RBV -> sp1l0.diode_vert default operator [None, 97, 99]
SP1L0:KMONO:MMS:RET_VERT.RBV -> sp1l0.ret_vert default operator [None, -1, 1]
SP1L0:KMONO:MMS:XTAL_ANGLE.RBV -> sp1l0.xtal_angle firstFill [None, -0.2, 0.2]
SP1L0:KMONO:MMS:XTAL_VERT.RBV -> sp1l0.xtal_vert default operator [None, -0.2, 0.2]
TV1L0:VGC:01:OPN_DI_RBV -> tv1l0_vgc01.open_limit default operator [None, 1]
TV2L0:VGC:01:OPN_DI_RBV -> tv2l0_vgc01.open_limit default operator [None, 1]
TV2L0:VGC:02:OPN_DI_RBV -> tv2l0_vgc02.open_limit default operator [None, 1]
TV3L0:VFS:01:OPN_DI_RBV -> tv3l0_vfs01.position_open default operator [None, 1]
TV4L0:VGC:01:OPN_DI_RBV -> tv4l0_vgc01.open_limit default operator [None, 1]
TV4L0:VGC:01:OPN_DI_RBV -> tv4l0_vgc01.open_limit lastFill [None, 80]
TV5L0:VGC:01:OPN_DI_RBV -> tv5l0_vgc01.open_limit default operator [None, 1]
TV5L0:VGC:01:OPN_DI_RBV -> tv5l0_vgc01.open_limit lastFill [None, 80]
TV6L0:VGC:01:AT_VAC_RBV -> tv6l0_vgc01.at_vac lastFill [None, 80]
TV6L0:VGC:01:AT_VAC_SP_RBV -> tv6l0_vgc01.at_vac_setpoint lastFill [None, 80]
TV6L0:VGC:01:CLS_DI_RBV -> tv6l0_vgc01.closed_limit lastFill [None, 80]
TV6L0:VGC:01:OPN_DI_RBV -> tv6l0_vgc01.open_limit default operator [None, 1]
TV6L0:VGC:01:OPN_DI_RBV -> tv6l0_vgc01.open_limit lastFill [None, 80]
TV6L0:VGC:01:OPN_SW_RBV -> tv6l0_vgc01.open_command lastFill [None, 80]
TV6L0:VGC:01:STATE_RBV -> tv6l0_vgc01.state lastFill [None, 80]

PVs not yet mapped to a happi item:

AT2L0:SOLID:GCC:01:PRESS_RBV
AT2L0:SOLID:PIN:01:PRESS_RBV
BT2L0:PLEG:GCC:01:PRESS_RBV
BT2L0:PLEG:PIP:01:PRESS_RBV
EM1L0:GEM:GCM:40:PRESS_RBV
EM1L0:GEM:VAC:OVRDON_RBV
EM2L0:GEM:GCM:40:PRESS_RBV
EM2L0:GEM:VAC:OVRDON_RBV
HVCH:FEE1:241:VoltageMeasure
HVCH:FEE1:242:VoltageMeasure
HVCH:FEE1:361:VoltageMeasure
HVCH:FEE1:362:VoltageMeasure
HX3:MON:GCC:01:PMON
HX3:MON:GCC:01:PMON
MR1L0:HOMS:GCC:01:PRESS_RBV
MR2L0:HOMS:GCC:01:PRESS_RBV
MR2L0:HOMS:PIP:01:PRESS_RBV
PA1L0:GCC:01:PRESS_RBV
PA1L0:PIN:01:PRESS_RBV
PLC:LFE:VAC:EBD:OVRDON_RBV
PLC:LFE:VAC:FEE:OVRDON_RBV
PLC:LFE:VAC:H1_1H1_2:OVRDON_RBV
RTDSL0:PIP:01:PRESS_RBV
RTDSL0:PIP:02:PRESS_RBV
RTDSL0:PIP:03:PRESS_RBV
RTDSL0:PIP:04:PRESS_RBV
RTDSL0:PIP:05:PRESS_RBV
SL1L0:POWER:GCC:01:PRESS_RBV
SL1L0:POWER:PIN:01:PRESS_RBV
SMPS:FEE1:201:I
SMPS:FEE1:202:I
SP1L0:KMONO:GCC:01:PMON
SP1L0:KMONO:PIP:01:PRESS_RBV
ST1L0:XTES:GCC:01:PRESS_RBV
ST1L0:XTES:PIP:01:PRESS_RBV
TV1L0:GCC:01:PRESS_RBV
TV2L0:GCC:01:PRESS_RBV
TV2L0:GCC:02:PRESS_RBV
TV2L0:PIP:01:PRESS_RBV
TV2L0:PIP:02:PRESS_RBV
TV2L0:PIP:03:PRESS_RBV
TV3L0:PIP:01:PRESS_RBV
TV4L0:GCC:01:PRESS_RBV
TV4L0:GCC:02:PRESS_RBV
TV4L0:PIP:01:PRESS_RBV
TV4L0:PIP:02:PRESS_RBV
TV5L0:GCC:01:PRESS_RBV
TV5L0:GCC:02:PRESS_RBV
TV5L0:GCC:03:PRESS_RBV
TV5L0:GCC:03:PRESS_RBV
TV5L0:GFS:01:PRESS_RBV
TV5L0:PIP:01:PMON
TV5L0:PIP:01:PRESS_RBV
TV5L0:PIP:02:PMON
TV5L0:PIP:02:PRESS_RBV
TV5L0:PIP:03:PMON
TV5L0:PIP:03:PRESS_RBV
TV6L0:VGC:01:POS_STATE_RBV

@klauer
Copy link
Contributor Author

klauer commented Jan 12, 2022

Where does 'ready for beam' status fit into?
Should comparisons break up success into ready_for_beam = 0 and device_ok = 1?

@klauer
Copy link
Contributor Author

klauer commented Jan 13, 2022

For posterity, a quick script to load up the panels and correlate with whatrecord metadata:

import json
import apischema
from atef import grafana
from typing import Tuple


def split_record_and_field(pvname) -> Tuple[str, str]:
    """Split REC.FLD into REC and FLD."""
    record, *field = pvname.split(".", 1)
    return record, field[0] if field else ""


happi_info = json.load(open("happi.json"))
json_doc = json.load(open("atef/tests/hxr_ebd_fee_checkout_helper.json"))
dashboard = apischema.deserialize(grafana.Dashboard, json_doc)

record_to_happi_item = happi_info["record_to_metadata_keys"]
happi_items = happi_info["metadata_by_key"]

for panel in dashboard.panels:
    if isinstance(panel, grafana.RowPanel):
        continue

    try:
        thresholds = panel.fieldConfig.defaults.thresholds
    except AttributeError:
        thresholds = None

    targets = panel.targets_by_id
    by_pv = {
        split_record_and_field(target.target)[0]: target
        for target in targets.values()
    }
    for pv, target in by_pv.items():
        happi_item = record_to_happi_item.get(pv, None)
        if not happi_item:
            print(pv, "missing in happi")
        else:
            happi_item, *_ = happi_item
            attrs = [
                ".".join((happi_item, info["signal"])).rstrip(".")
                for info in happi_items[happi_item]["_whatrecord"]["records"]
                if info["name"] == pv
            ]
            steps = [(t.value, t.color) for t in thresholds.steps] if thresholds else None
            print(target.target, "->", attrs[0], target.operator or "default operator", steps)

@klauer
Copy link
Contributor Author

klauer commented Jan 20, 2022

CLI with rich could be passable:
image

Descriptions with attribute names still need some work (the warning above is missing which attribute failed, specifically).
Maybe this could look more like CI results?
Or maybe it could be a table?

@klauer
Copy link
Contributor Author

klauer commented Jan 20, 2022

A little better, even if not 100% grammatical:

Device at2l0_calc Internal error
  * Internal error: Checking if 'a' equal to 1 (for a result of success) raised AttributeError: a
  * Internal error: Checking if 'b' equal to 1 (for a result of success) raised AttributeError: b
  * Warning: actual_transmission equal to 0 (for a result of success): value of 1.0

@klauer
Copy link
Contributor Author

klauer commented Jan 21, 2022

Trees look decent and seem appropriate here:
image

@klauer
Copy link
Contributor Author

klauer commented Jan 25, 2022

It's getting more difficult to focus on atef work recently, so I'm going to mark this as ready for review now. It's still really a work-in-progress, but there is enough here for feedback at least.

Next steps:

  1. Fully convert all of the HXR EBD checkout panel thresholds into atef checks.
  2. Add data sampling + reducing by configured method for noisy signals
  3. Re-evaluate configurations/layout based on the above

@klauer klauer marked this pull request as ready for review January 25, 2022 17:25
@klauer klauer changed the title WIP: comparisons ENH: comparisons and "atef check" command-line tool Jan 25, 2022
@ZLLentz
Copy link
Member

ZLLentz commented Jan 26, 2022

I can try to dive into a review here tomorrow

Copy link
Member

@ZLLentz ZLLentz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left comments where I had thoughts to share
Did not check the tests yet, got tired
Probably good to merge

relative_module = f'.{module}'
return importlib.import_module(relative_module, 'atef.bin')
def _try_import(module_name):
return importlib.import_module(f".{module_name}", 'atef.bin')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What exactly is going on here? Why are we not importing these modules the normal way?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the "easily support various subcommands through a simpleish interface" thing I used in a bunch of projects and added to the cookiecutter: https://github.com/pcdshub/cookiecutter-pcds-python/blob/master/%7B%7B%20cookiecutter.folder_name%20%7D%7D/%7B%7B%20cookiecutter.import_name%20%7D%7D/bin/main.py

The short of it is: add your module name to the list, add functions for creating the arg parser + main, and you're done. If it's not importable for some reason (optional dependencies? other import-related error?) it'll still allow you to use other subcommands.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, seems ok. I just found it really confusing/strange and I don't expect any linters to be able to pick up on typos in the COMMAND_TO_MODULE dict the same way they would for something much more straightforward e.g.

from . import check
from . import grafana

SUBMODULES = (check, grafana)

COMMANDS = {
    module.__name__: module.build_arg_parser
    for module in SUBMODULES
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that was the original implementation in pytmc, but then sometimes pyqt5 wasn't installed - which made every pytmc subcommand (even those that didn't require pyqt5) inaccessible.

I think I could be convinced the benefits of the static checker outweigh the benefits of the current implementation. Try/catch on import, don't add it to the commands dict. I'm only not sure about how to let the user know the reason that the subcommand isn't available without being too annoying.

It may be worth revisiting, but I think probably outside of the scope of the PR. (And in scope of 4ish other projects that use the same structure 😁 )

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I mean, it confused me but if it does the thing it does the thing


from . import serialization

Number = Union[int, float]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm surprised this isn't a built-in, it would get a lot of use...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is numbers.Number, but for whatever reason my static type checking tool was raising errors on valid inputs. It may have been on my end, and I'd be curious to hear what you see in your setup.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I was also having trouble with that one last time I tried (in that big daq pr)

success = 0
warning = 1
error = 2
internal_error = 3
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume this is differentiating between "my device has an issue" and "my test has an issue", really good distinction

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, exactly. I have notes elsewhere here about not knowing where "OK for beam" and "device seems OK" fits in, though.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My two immediate thought threads on this are basically:

  • Can we simply tag tests as "beam tests" and "device tests"?
  • Can we include some sort of "consequence" enum in the failure information of a test?

not 100% sure here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I think the beam vs device test may be a good way to separate it out... I think we'll need to whiteboard this to really understand the implications of it all though.

ReduceMethod.min: np.min,
ReduceMethod.max: np.max,
ReduceMethod.std: np.std,
}[self]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've never considered this enum pattern before, it seems like it could be useful



success = Result()
PrimitiveType = Union[str, int, bool, float]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is another "why not in standard library" typing thing

return f"{self.description} ({value_desc})"
return value_desc

def compare(self, value: PrimitiveType) -> bool:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any thoughts on using dunder methods like __eq__ for things like this?

I'm not sure how I feel. Sometimes object == 5 feels nice, other times it gets confusing when comparing an object to a primitive.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After reading on, it makes sense to keep this as a normal method, unless we wanted to go full confusion by enabling constructs like value in value_range

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An interesting thought, I think - but as your second comment notes, the grammar gets confusing for the non-equality-like variants. It could be named something else like "check" or "run" or...

class ValueSet(Comparison):
"""A set of values with corresponding severities and descriptions."""
# Review: really a "value sequence"/list as the first ones have priority,
# but that sounds like a vector version of "Value" above; better ideas?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this one should be AnyValue and the next should be AnyPrimitive?

cleanup: bool = True
):
serialized_config = yaml.safe_load(open(filename))
config = apischema.deserialize(ConfigurationFile, serialized_config)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Somehow I missed that this was possible- I'm giving apischema another look now. Very cool.

"""Clean up ophyd - avoid teardown errors by stopping callbacks."""
dispatcher = ophyd.cl.get_dispatcher()
if dispatcher is not None:
dispatcher.stop()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neat trick

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Segfaults otherwise for short-lived sessions with trying-to-connect PVs 😬
I wonder if ophyd doesn't have an atexit hook for stopping the dispatcher - I could have sworn it did.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

@ZLLentz ZLLentz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left comments where I had thoughts to share
Did not check the tests yet, got tired
Probably good to merge

@klauer klauer merged commit 67edfa4 into pcdshub:master Feb 18, 2022
@klauer klauer deleted the enh_comparison branch February 18, 2022 21:40
tangkong pushed a commit to tangkong/atef that referenced this pull request Feb 7, 2023
ENH: comparisons and "atef check" command-line tool
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants