apsbits 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apsbits/__init__.py +18 -0
- apsbits/_version.py +21 -0
- apsbits/core/__init__.py +11 -0
- apsbits/core/best_effort_init.py +51 -0
- apsbits/core/catalog_init.py +36 -0
- apsbits/core/run_engine_init.py +118 -0
- apsbits/demo_instrument/README.md +1 -0
- apsbits/demo_instrument/__init__.py +22 -0
- apsbits/demo_instrument/callbacks/__init__.py +1 -0
- apsbits/demo_instrument/callbacks/nexus_data_file_writer.py +58 -0
- apsbits/demo_instrument/callbacks/spec_data_file_writer.py +97 -0
- apsbits/demo_instrument/configs/__init__.py +1 -0
- apsbits/demo_instrument/configs/devices.yml +52 -0
- apsbits/demo_instrument/configs/devices_aps_only.yml +6 -0
- apsbits/demo_instrument/configs/iconfig.yml +82 -0
- apsbits/demo_instrument/configs/logging.yml +41 -0
- apsbits/demo_instrument/devices/__init__.py +1 -0
- apsbits/demo_instrument/plans/__init__.py +8 -0
- apsbits/demo_instrument/plans/dm_plans.py +111 -0
- apsbits/demo_instrument/plans/sim_plans.py +69 -0
- apsbits/demo_instrument/startup.py +76 -0
- apsbits/demo_qserver/qs-config.yml +51 -0
- apsbits/demo_qserver/qs_host.sh +231 -0
- apsbits/demo_qserver/user_group_permissions.yaml +46 -0
- apsbits/tests/__init__.py +1 -0
- apsbits/tests/conftest.py +39 -0
- apsbits/tests/test_config.py +113 -0
- apsbits/tests/test_device_factories.py +44 -0
- apsbits/tests/test_general.py +98 -0
- apsbits/tests/test_stored_dict.py +139 -0
- apsbits/utils/__init__.py +1 -0
- apsbits/utils/aps_functions.py +67 -0
- apsbits/utils/config_loaders.py +169 -0
- apsbits/utils/controls_setup.py +107 -0
- apsbits/utils/create_new_instrument.py +129 -0
- apsbits/utils/helper_functions.py +123 -0
- apsbits/utils/logging_setup.py +211 -0
- apsbits/utils/make_devices.py +162 -0
- apsbits/utils/metadata.py +96 -0
- apsbits/utils/sim_creator.py +202 -0
- apsbits/utils/stored_dict.py +174 -0
- apsbits-1.0.0.dist-info/METADATA +195 -0
- apsbits-1.0.0.dist-info/RECORD +47 -0
- apsbits-1.0.0.dist-info/WHEEL +5 -0
- apsbits-1.0.0.dist-info/entry_points.txt +2 -0
- apsbits-1.0.0.dist-info/licenses/LICENSE +48 -0
- apsbits-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Make devices from YAML files
|
|
3
|
+
=============================
|
|
4
|
+
|
|
5
|
+
Construct ophyd-style devices from simple specifications in YAML files.
|
|
6
|
+
|
|
7
|
+
.. autosummary::
|
|
8
|
+
:nosignatures:
|
|
9
|
+
|
|
10
|
+
~make_devices
|
|
11
|
+
~Instrument
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import pathlib
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
import guarneri
|
|
20
|
+
from apstools.plans import run_blocking_function
|
|
21
|
+
from apstools.utils import dynamic_import
|
|
22
|
+
from bluesky import plan_stubs as bps
|
|
23
|
+
|
|
24
|
+
from apsbits.utils.aps_functions import host_on_aps_subnet
|
|
25
|
+
from apsbits.utils.config_loaders import get_config
|
|
26
|
+
from apsbits.utils.config_loaders import load_config_yaml
|
|
27
|
+
from apsbits.utils.controls_setup import oregistry # noqa: F401
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
logger.bsdev(__file__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def make_devices(*, pause: float = 1):
|
|
34
|
+
"""
|
|
35
|
+
(plan stub) Create the ophyd-style controls for this instrument.
|
|
36
|
+
|
|
37
|
+
Feel free to modify this plan to suit the needs of your instrument.
|
|
38
|
+
|
|
39
|
+
EXAMPLE::
|
|
40
|
+
|
|
41
|
+
RE(make_devices())
|
|
42
|
+
|
|
43
|
+
PARAMETERS
|
|
44
|
+
|
|
45
|
+
pause : float
|
|
46
|
+
Wait 'pause' seconds (default: 1) for slow objects to connect.
|
|
47
|
+
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
logger.debug("(Re)Loading local control objects.")
|
|
51
|
+
|
|
52
|
+
iconfig = get_config()
|
|
53
|
+
|
|
54
|
+
instrument_path = pathlib.Path(iconfig.get("INSTRUMENT_PATH")).parent
|
|
55
|
+
configs_path = instrument_path / "configs"
|
|
56
|
+
|
|
57
|
+
# Get device files and ensure it's a list
|
|
58
|
+
device_files = iconfig.get("DEVICES_FILES", [])
|
|
59
|
+
if isinstance(device_files, str):
|
|
60
|
+
device_files = [device_files]
|
|
61
|
+
logger.debug("Loading device files: %r", device_files)
|
|
62
|
+
|
|
63
|
+
# Load each device file
|
|
64
|
+
for device_file in device_files:
|
|
65
|
+
device_path = configs_path / device_file
|
|
66
|
+
if not device_path.exists():
|
|
67
|
+
logger.error(f"Device file not found: {device_path}")
|
|
68
|
+
continue
|
|
69
|
+
logger.info(f"Loading device file: {device_path}")
|
|
70
|
+
try:
|
|
71
|
+
yield from run_blocking_function(_loader, device_path, main=True)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
logger.error(f"Error loading device file {device_path}: {str(e)}")
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
# Handle APS-specific device files if on APS subnet
|
|
77
|
+
aps_control_devices_files = iconfig.get("APS_DEVICES_FILES", [])
|
|
78
|
+
if isinstance(aps_control_devices_files, str):
|
|
79
|
+
aps_control_devices_files = [aps_control_devices_files]
|
|
80
|
+
|
|
81
|
+
if aps_control_devices_files and host_on_aps_subnet():
|
|
82
|
+
for device_file in aps_control_devices_files:
|
|
83
|
+
device_path = configs_path / device_file
|
|
84
|
+
if not device_path.exists():
|
|
85
|
+
logger.error(f"APS device file not found: {device_path}")
|
|
86
|
+
continue
|
|
87
|
+
logger.info(f"Loading APS device file: {device_path}")
|
|
88
|
+
try:
|
|
89
|
+
yield from run_blocking_function(_loader, device_path, main=True)
|
|
90
|
+
except Exception as e:
|
|
91
|
+
logger.error(f"Error loading APS device file {device_path}: {str(e)}")
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
if pause > 0:
|
|
95
|
+
logger.debug(
|
|
96
|
+
"Waiting %s seconds for slow objects to connect.",
|
|
97
|
+
pause,
|
|
98
|
+
)
|
|
99
|
+
yield from bps.sleep(pause)
|
|
100
|
+
|
|
101
|
+
# Configure any of the controls here, or in plan stubs
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _loader(yaml_device_file, main=True):
|
|
105
|
+
"""
|
|
106
|
+
Load our ophyd-style controls as described in a YAML file.
|
|
107
|
+
|
|
108
|
+
PARAMETERS
|
|
109
|
+
|
|
110
|
+
yaml_device_file : str or pathlib.Path
|
|
111
|
+
YAML file describing ophyd-style controls to be created.
|
|
112
|
+
main : bool
|
|
113
|
+
If ``True`` add these devices to the ``__main__`` namespace.
|
|
114
|
+
|
|
115
|
+
"""
|
|
116
|
+
logger.debug("Devices file %r.", str(yaml_device_file))
|
|
117
|
+
t0 = time.time()
|
|
118
|
+
_instr.load(yaml_device_file)
|
|
119
|
+
logger.info("Devices loaded in %.3f s.", time.time() - t0)
|
|
120
|
+
|
|
121
|
+
if main:
|
|
122
|
+
main_namespace = sys.modules["__main__"]
|
|
123
|
+
for label in oregistry.device_names:
|
|
124
|
+
logger.info(f"Setting up {label} in main namespace")
|
|
125
|
+
setattr(main_namespace, label, oregistry[label])
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class Instrument(guarneri.Instrument):
|
|
129
|
+
"""Custom YAML loader for guarneri."""
|
|
130
|
+
|
|
131
|
+
def parse_yaml_file(self, config_file: pathlib.Path | str) -> list[dict]:
|
|
132
|
+
"""Read device configurations from YAML format file."""
|
|
133
|
+
if isinstance(config_file, str):
|
|
134
|
+
config_file = pathlib.Path(config_file)
|
|
135
|
+
|
|
136
|
+
def parser(creator, specs):
|
|
137
|
+
if creator not in self.device_classes:
|
|
138
|
+
self.device_classes[creator] = dynamic_import(creator)
|
|
139
|
+
entries = [
|
|
140
|
+
{
|
|
141
|
+
"device_class": creator,
|
|
142
|
+
"args": (), # ALL specs are kwargs!
|
|
143
|
+
"kwargs": table,
|
|
144
|
+
}
|
|
145
|
+
for table in specs
|
|
146
|
+
]
|
|
147
|
+
return entries
|
|
148
|
+
|
|
149
|
+
with open(config_file, "r") as f:
|
|
150
|
+
config_data = load_config_yaml(f)
|
|
151
|
+
|
|
152
|
+
devices = [
|
|
153
|
+
device
|
|
154
|
+
# parse the file using already loaded config data
|
|
155
|
+
for k, v in config_data.items()
|
|
156
|
+
# each support type (class, factory, function, ...)
|
|
157
|
+
for device in parser(k, v)
|
|
158
|
+
]
|
|
159
|
+
return devices
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
_instr = Instrument({}, registry=oregistry) # singleton
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RunEngine Metadata
|
|
3
|
+
==================
|
|
4
|
+
|
|
5
|
+
.. autosummary::
|
|
6
|
+
~MD_PATH
|
|
7
|
+
~get_md_path
|
|
8
|
+
~re_metadata
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import getpass
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import pathlib
|
|
15
|
+
import socket
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
import apstools
|
|
19
|
+
import bluesky
|
|
20
|
+
import databroker
|
|
21
|
+
import epics
|
|
22
|
+
import h5py
|
|
23
|
+
import intake
|
|
24
|
+
import matplotlib
|
|
25
|
+
import numpy
|
|
26
|
+
import ophyd
|
|
27
|
+
import pyRestTable
|
|
28
|
+
import pysumreg
|
|
29
|
+
import spec2nexus
|
|
30
|
+
|
|
31
|
+
import apsbits
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
logger.bsdev(__file__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
DEFAULT_MD_PATH = pathlib.Path.home() / ".config" / "Bluesky_RunEngine_md"
|
|
38
|
+
HOSTNAME = socket.gethostname() or "localhost"
|
|
39
|
+
USERNAME = getpass.getuser() or "Bluesky user"
|
|
40
|
+
VERSIONS = dict(
|
|
41
|
+
apstools=apstools.__version__,
|
|
42
|
+
bluesky=bluesky.__version__,
|
|
43
|
+
databroker=databroker.__version__,
|
|
44
|
+
epics=epics.__version__,
|
|
45
|
+
h5py=h5py.__version__,
|
|
46
|
+
intake=intake.__version__,
|
|
47
|
+
matplotlib=matplotlib.__version__,
|
|
48
|
+
numpy=numpy.__version__,
|
|
49
|
+
ophyd=ophyd.__version__,
|
|
50
|
+
pyRestTable=pyRestTable.__version__,
|
|
51
|
+
python=sys.version.split(" ")[0],
|
|
52
|
+
pysumreg=pysumreg.__version__,
|
|
53
|
+
spec2nexus=spec2nexus.__version__,
|
|
54
|
+
apsbits=apsbits.__version__,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_md_path(iconfig=None):
|
|
59
|
+
"""
|
|
60
|
+
Get path for RE metadata.
|
|
61
|
+
|
|
62
|
+
============== ==============================================
|
|
63
|
+
support path
|
|
64
|
+
============== ==============================================
|
|
65
|
+
PersistentDict Directory where dictionary keys are stored in separate files.
|
|
66
|
+
StoredDict File where dictionary is stored as YAML.
|
|
67
|
+
============== ==============================================
|
|
68
|
+
|
|
69
|
+
In either case, the 'path' can be relative or absolute. Relative
|
|
70
|
+
paths are with respect to the present working directory when the
|
|
71
|
+
bluesky session is started.
|
|
72
|
+
"""
|
|
73
|
+
RE_CONFIG = iconfig.get("RUN_ENGINE", {})
|
|
74
|
+
md_path_name = RE_CONFIG.get("MD_PATH", DEFAULT_MD_PATH)
|
|
75
|
+
path = pathlib.Path(md_path_name)
|
|
76
|
+
logger.info("RunEngine metadata saved in directory: %s", str(path))
|
|
77
|
+
return str(path)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def re_metadata(iconfig=None, cat=None):
|
|
81
|
+
"""Programmatic metadata for the RunEngine."""
|
|
82
|
+
md = {
|
|
83
|
+
"login_id": f"{USERNAME}@{HOSTNAME}",
|
|
84
|
+
"versions": VERSIONS,
|
|
85
|
+
"pid": os.getpid(),
|
|
86
|
+
"iconfig": iconfig,
|
|
87
|
+
}
|
|
88
|
+
if cat is not None:
|
|
89
|
+
md["databroker_catalog"] = cat.name
|
|
90
|
+
RE_CONFIG = iconfig.get("RUN_ENGINE", {})
|
|
91
|
+
md.update(RE_CONFIG.get("DEFAULT_METADATA", {}))
|
|
92
|
+
|
|
93
|
+
conda_prefix = os.environ.get("CONDA_PREFIX")
|
|
94
|
+
if conda_prefix is not None:
|
|
95
|
+
md["conda_prefix"] = conda_prefix
|
|
96
|
+
return md
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Device factories
|
|
3
|
+
================
|
|
4
|
+
|
|
5
|
+
Device factories are used to:
|
|
6
|
+
|
|
7
|
+
* *Create* several similar ophyd-style devices (such as ``ophyd.Device``
|
|
8
|
+
or ``ophyd.Signal``) that fit a pattern.
|
|
9
|
+
|
|
10
|
+
* *Import* a device which is pre-defined in a module, such as the
|
|
11
|
+
ophyd simulators in ``ophyd.sim``.
|
|
12
|
+
|
|
13
|
+
.. autosummary::
|
|
14
|
+
|
|
15
|
+
~factory_base
|
|
16
|
+
~motors
|
|
17
|
+
~predefined_device
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
|
|
22
|
+
from apstools.utils import dynamic_import
|
|
23
|
+
|
|
24
|
+
from apsbits.utils.controls_setup import oregistry
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
logger.bsdev(__file__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def predefined_device(*, name="", creator=""):
|
|
31
|
+
"""
|
|
32
|
+
Provide a predefined device such as from the 'ophyd.sim' module.
|
|
33
|
+
|
|
34
|
+
PARAMETERS
|
|
35
|
+
|
|
36
|
+
creator : str
|
|
37
|
+
Name of the predefined device to be used
|
|
38
|
+
name : str
|
|
39
|
+
Simulator will be assigned this name. (default: use existing name)
|
|
40
|
+
|
|
41
|
+
Example entry in `devices.yml` file:
|
|
42
|
+
|
|
43
|
+
.. code-block:: yaml
|
|
44
|
+
:linenos:
|
|
45
|
+
|
|
46
|
+
instrument.devices.factories.predefined_device:
|
|
47
|
+
- {creator: ophyd.sim.motor, name: sim_motor}
|
|
48
|
+
- {creator: ophyd.sim.noisy_det, name: sim_det}
|
|
49
|
+
"""
|
|
50
|
+
if creator == "":
|
|
51
|
+
raise ValueError("Must provide a value for 'creator'.")
|
|
52
|
+
device = dynamic_import(creator)
|
|
53
|
+
if name != "":
|
|
54
|
+
device.name = name
|
|
55
|
+
logger.debug(device)
|
|
56
|
+
oregistry.register(device)
|
|
57
|
+
yield device
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def factory_base(
|
|
61
|
+
*,
|
|
62
|
+
prefix=None,
|
|
63
|
+
names="object{}",
|
|
64
|
+
first=0,
|
|
65
|
+
last=0,
|
|
66
|
+
creator="ophyd.Signal",
|
|
67
|
+
**kwargs,
|
|
68
|
+
):
|
|
69
|
+
"""
|
|
70
|
+
Make one or more objects using 'creator'.
|
|
71
|
+
|
|
72
|
+
PARAMETERS
|
|
73
|
+
|
|
74
|
+
prefix : str
|
|
75
|
+
Prefix *pattern* for the EPICS PVs (default: ``None``).
|
|
76
|
+
|
|
77
|
+
names : str
|
|
78
|
+
Name *pattern* for the objects. The default pattern is ``"object{}"`` which
|
|
79
|
+
produces devices named ``object1, object2, ..., ```. If a formatting
|
|
80
|
+
specification (``{}``) is not provided, it is appended. Each object
|
|
81
|
+
will be named using this code: ``names.format(number)``, such as::
|
|
82
|
+
|
|
83
|
+
In [23]: "object{}".format(22)
|
|
84
|
+
Out[23]: 'object22'
|
|
85
|
+
|
|
86
|
+
first : int
|
|
87
|
+
The first object number in the continuous series from 'first' through
|
|
88
|
+
'last' (inclusive).
|
|
89
|
+
|
|
90
|
+
last : int
|
|
91
|
+
The first object number in the continuous series from 'first' through
|
|
92
|
+
'last' (inclusive).
|
|
93
|
+
|
|
94
|
+
creator : str
|
|
95
|
+
Name of the *creator* code that will be used to construct each device.
|
|
96
|
+
(default: ``"ophyd.Signal"``)
|
|
97
|
+
|
|
98
|
+
kwargs : dict
|
|
99
|
+
Dictionary of additional keyword arguments. This is included
|
|
100
|
+
when creating each object.
|
|
101
|
+
"""
|
|
102
|
+
if "{" not in names:
|
|
103
|
+
names += "{}"
|
|
104
|
+
if prefix is not None and "{" not in prefix:
|
|
105
|
+
prefix += "{}"
|
|
106
|
+
|
|
107
|
+
klass = dynamic_import(creator)
|
|
108
|
+
|
|
109
|
+
first, last = sorted([first, last])
|
|
110
|
+
for i in range(first, 1 + last):
|
|
111
|
+
keywords = {"name": names.format(i)}
|
|
112
|
+
if prefix is not None:
|
|
113
|
+
keywords["prefix"] = prefix.format(i)
|
|
114
|
+
keywords.update(kwargs)
|
|
115
|
+
device = klass(**keywords)
|
|
116
|
+
logger.debug(device)
|
|
117
|
+
yield device
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def motors(
|
|
121
|
+
*,
|
|
122
|
+
prefix=None,
|
|
123
|
+
names="m{}",
|
|
124
|
+
first=0,
|
|
125
|
+
last=0,
|
|
126
|
+
**kwargs,
|
|
127
|
+
):
|
|
128
|
+
"""
|
|
129
|
+
Make one or more '``ophyd.EpicsMotor``' objects.
|
|
130
|
+
|
|
131
|
+
Example entry in `devices.yml` file:
|
|
132
|
+
|
|
133
|
+
.. code-block:: yaml
|
|
134
|
+
:linenos:
|
|
135
|
+
|
|
136
|
+
instrument.devices.factories.motors:
|
|
137
|
+
- {prefix: "ioc:m", first: 1, last: 4, labels: ["motor"]}
|
|
138
|
+
# skip m5 & m6
|
|
139
|
+
- {prefix: "ioc:m", first: 7, last: 22, labels: ["motor"]}
|
|
140
|
+
|
|
141
|
+
Uses this pattern:
|
|
142
|
+
|
|
143
|
+
.. code-block:: py
|
|
144
|
+
:linenos:
|
|
145
|
+
|
|
146
|
+
ophyd.EpicsMotor(
|
|
147
|
+
prefix=prefix.format(i),
|
|
148
|
+
name=names.format(i),
|
|
149
|
+
**kwargs,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
where ``i`` iterates from 'first' through 'last' (inclusive).
|
|
153
|
+
|
|
154
|
+
PARAMETERS
|
|
155
|
+
|
|
156
|
+
prefix : str
|
|
157
|
+
Name *pattern* for the EPICS PVs. There is no default pattern. If a
|
|
158
|
+
formatting specification (``{}``) is not provided, it is appended (as
|
|
159
|
+
with other ophyd devices). Each motor will be configured with this
|
|
160
|
+
prefix: ``prefix.format(number)``, such as::
|
|
161
|
+
|
|
162
|
+
In [23]: "ioc:m{}".format(22)
|
|
163
|
+
Out[23]: 'ioc:m22'
|
|
164
|
+
|
|
165
|
+
names : str
|
|
166
|
+
Name *pattern* for the motors. The default pattern is ``"m{}"`` which
|
|
167
|
+
produces motors named ``m1, m2, ..., m22, m23, ...```. If a formatting
|
|
168
|
+
specification (``{}``) is not provided, it is appended. Each motor
|
|
169
|
+
will be named using this code: ``names.format(number)``, such as::
|
|
170
|
+
|
|
171
|
+
In [23]: "m{}".format(22)
|
|
172
|
+
Out[23]: 'm22'
|
|
173
|
+
|
|
174
|
+
first : int
|
|
175
|
+
The first motor number in the continuous series from 'first' through
|
|
176
|
+
'last' (inclusive).
|
|
177
|
+
|
|
178
|
+
last : int
|
|
179
|
+
The first motor number in the continuous series from 'first' through
|
|
180
|
+
'last' (inclusive).
|
|
181
|
+
|
|
182
|
+
kwargs : dict
|
|
183
|
+
Dictionary of additional keyword arguments. This is included
|
|
184
|
+
with each EpicsMotor object.
|
|
185
|
+
"""
|
|
186
|
+
if prefix is None:
|
|
187
|
+
raise ValueError("Must define a string value for 'prefix'.")
|
|
188
|
+
|
|
189
|
+
kwargs["names"] = names or "m{}"
|
|
190
|
+
kwargs["prefix"] = prefix
|
|
191
|
+
kwargs.update(
|
|
192
|
+
{
|
|
193
|
+
"prefix": prefix,
|
|
194
|
+
"names": names or "m{}",
|
|
195
|
+
"first": first,
|
|
196
|
+
"last": last,
|
|
197
|
+
"creator": "ophyd.EpicsMotor",
|
|
198
|
+
}
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
for motor in factory_base(**kwargs):
|
|
202
|
+
yield motor
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Storage-backed Dictionary
|
|
3
|
+
=========================
|
|
4
|
+
A dictionary that writes its contents to YAML file.
|
|
5
|
+
Replaces ``bluesky.utils.PersistentDict``.
|
|
6
|
+
* Contents must be JSON serializable.
|
|
7
|
+
* Contents stored in a single human-readable YAML file.
|
|
8
|
+
* Sync to disk shortly after dictionary is updated.
|
|
9
|
+
.. autosummary::
|
|
10
|
+
~StoredDict
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
__all__ = ["StoredDict"]
|
|
14
|
+
|
|
15
|
+
import collections.abc
|
|
16
|
+
import datetime
|
|
17
|
+
import inspect
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import pathlib
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
import yaml
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
logger.bsdev(__file__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class StoredDict(collections.abc.MutableMapping):
|
|
31
|
+
"""
|
|
32
|
+
Dictionary that syncs to storage.
|
|
33
|
+
.. autosummary::
|
|
34
|
+
~flush
|
|
35
|
+
~popitem
|
|
36
|
+
~reload
|
|
37
|
+
.. rubric:: Static methods
|
|
38
|
+
All support for the YAML format is implemented in the static methods.
|
|
39
|
+
.. autosummary::
|
|
40
|
+
~dump
|
|
41
|
+
~load
|
|
42
|
+
----
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, file, delay=5, title=None, serializable=True):
|
|
46
|
+
"""
|
|
47
|
+
StoredDict : Dictionary that syncs to storage
|
|
48
|
+
PARAMETERS
|
|
49
|
+
file : str or pathlib.Path
|
|
50
|
+
Name of file to store dictionary contents.
|
|
51
|
+
delay : number
|
|
52
|
+
Time delay (s) since last dictionary update to write to storage.
|
|
53
|
+
Default: 5 seconds.
|
|
54
|
+
title : str or None
|
|
55
|
+
Comment to write at top of file.
|
|
56
|
+
Default: "Written by StoredDict."
|
|
57
|
+
serializable : bool
|
|
58
|
+
If True, validate new dictionary entries are JSON serializable.
|
|
59
|
+
"""
|
|
60
|
+
self._file = pathlib.Path(file)
|
|
61
|
+
self._delay = max(0, delay)
|
|
62
|
+
self._title = title or f"Written by {self.__class__.__name__}."
|
|
63
|
+
self.test_serializable = serializable
|
|
64
|
+
self.sync_in_progress = False
|
|
65
|
+
self._sync_deadline = time.time()
|
|
66
|
+
self._sync_key = f"sync_agent_{id(self):x}"
|
|
67
|
+
self._sync_loop_period = 0.005
|
|
68
|
+
|
|
69
|
+
self._cache = {}
|
|
70
|
+
self.reload()
|
|
71
|
+
|
|
72
|
+
def __delitem__(self, key):
|
|
73
|
+
"""Delete dictionary value by key."""
|
|
74
|
+
del self._cache[key]
|
|
75
|
+
|
|
76
|
+
def __getitem__(self, key):
|
|
77
|
+
"""Get dictionary value by key."""
|
|
78
|
+
return self._cache[key]
|
|
79
|
+
|
|
80
|
+
def __iter__(self):
|
|
81
|
+
"""Iterate over the dictionary keys."""
|
|
82
|
+
yield from self._cache
|
|
83
|
+
|
|
84
|
+
def __len__(self):
|
|
85
|
+
"""Number of keys in the dictionary."""
|
|
86
|
+
return len(self._cache)
|
|
87
|
+
|
|
88
|
+
def __repr__(self):
|
|
89
|
+
"""representation of this object."""
|
|
90
|
+
return f"<{self.__class__.__name__} {dict(self)!r}>"
|
|
91
|
+
|
|
92
|
+
def __setitem__(self, key, value):
|
|
93
|
+
"""Write to the dictionary."""
|
|
94
|
+
outermost_frame = inspect.getouterframes(inspect.currentframe())[-1]
|
|
95
|
+
if "sphinx-build" in outermost_frame.filename:
|
|
96
|
+
# Seems that Sphinx is building the documentation.
|
|
97
|
+
# Ignore all the objects it tries to add.
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
if self.test_serializable:
|
|
101
|
+
json.dumps({key: value})
|
|
102
|
+
self._cache[key] = value # Store the new (or revised) content.
|
|
103
|
+
|
|
104
|
+
# Reset the deadline.
|
|
105
|
+
self._sync_deadline = time.time() + self._delay
|
|
106
|
+
logger.debug("new sync deadline in %f s.", self._delay)
|
|
107
|
+
if not self.sync_in_progress:
|
|
108
|
+
# Start the sync_agent (thread).
|
|
109
|
+
self._delayed_sync_to_storage()
|
|
110
|
+
|
|
111
|
+
def _delayed_sync_to_storage(self):
|
|
112
|
+
"""
|
|
113
|
+
Sync the metadata to storage.
|
|
114
|
+
Start a time-delay thread. New writes to the metadata dictionary will
|
|
115
|
+
extend the deadline. Sync once the deadline is reached.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def sync_agent():
|
|
119
|
+
"""Threaded task."""
|
|
120
|
+
logger.debug("Starting sync_agent...")
|
|
121
|
+
self.sync_in_progress = True
|
|
122
|
+
while time.time() < self._sync_deadline:
|
|
123
|
+
time.sleep(self._sync_loop_period)
|
|
124
|
+
logger.debug("Sync waiting period ended")
|
|
125
|
+
self.sync_in_progress = False
|
|
126
|
+
|
|
127
|
+
StoredDict.dump(self._file, self._cache, title=self._title)
|
|
128
|
+
|
|
129
|
+
thred = threading.Thread(target=sync_agent)
|
|
130
|
+
thred.start()
|
|
131
|
+
|
|
132
|
+
def flush(self):
|
|
133
|
+
"""Force a write of the dictionary to disk"""
|
|
134
|
+
logger.debug("flush()")
|
|
135
|
+
if not self.sync_in_progress:
|
|
136
|
+
StoredDict.dump(self._file, self._cache, title=self._title)
|
|
137
|
+
self._sync_deadline = time.time()
|
|
138
|
+
self.sync_in_progress = False
|
|
139
|
+
|
|
140
|
+
def popitem(self):
|
|
141
|
+
"""
|
|
142
|
+
Remove and return a (key, value) pair as a 2-tuple.
|
|
143
|
+
|
|
144
|
+
Pairs are returned in LIFO (last-in, first-out) order.
|
|
145
|
+
Raises KeyError if the dict is empty.
|
|
146
|
+
"""
|
|
147
|
+
return self._cache.popitem()
|
|
148
|
+
|
|
149
|
+
def reload(self):
|
|
150
|
+
"""Read dictionary from storage."""
|
|
151
|
+
logger.debug("reload()")
|
|
152
|
+
self._cache = StoredDict.load(self._file)
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def dump(file, contents, title=None):
|
|
156
|
+
"""Write dictionary to YAML file."""
|
|
157
|
+
logger.debug("_dump(): file='%s', contents=%r, title=%r", file, contents, title)
|
|
158
|
+
with open(file, "w") as f:
|
|
159
|
+
if isinstance(title, str) and len(title) > 0:
|
|
160
|
+
f.write(f"# {title}\n")
|
|
161
|
+
f.write(f"# Dictionary contents written: {datetime.datetime.now()}\n\n")
|
|
162
|
+
f.write(yaml.dump(contents, indent=2))
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def load(file):
|
|
166
|
+
"""Read dictionary from YAML file."""
|
|
167
|
+
from .config_loaders import load_config_yaml
|
|
168
|
+
|
|
169
|
+
file = pathlib.Path(file)
|
|
170
|
+
logger.debug("_load('%s')", file)
|
|
171
|
+
md = None
|
|
172
|
+
if file.exists():
|
|
173
|
+
md = load_config_yaml(file)
|
|
174
|
+
return md or {} # In case file is empty.
|