Skip to content

Commit fd1b8c4

Browse files
authored
Merge pull request #1060 from BCDA-APS/hoist-StoredDict
hoist StoredDict from bs_model_instrument @MDecarabas Thanks!
2 parents 88f366d + 9e6de76 commit fd1b8c4

File tree

5 files changed

+453
-2
lines changed

5 files changed

+453
-2
lines changed

apstools/devices/tests/test_positioner_soft_done.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from ..positioner_soft_done import PVPositionerSoftDoneWithStop
1919

2020
PV_PREFIX = f"{IOC_GP}gp:"
21+
DEFAULT_DELAY = 1
2122
delay_active = False
2223

2324

@@ -118,7 +119,7 @@ def confirm_in_position(p, dt):
118119

119120

120121
@run_in_thread
121-
def delayed_complete(positioner, readback, delay=1):
122+
def delayed_complete(positioner, readback, delay=DEFAULT_DELAY):
122123
"Time-delayed completion of positioner move."
123124
global delay_active
124125

@@ -129,7 +130,7 @@ def delayed_complete(positioner, readback, delay=1):
129130

130131

131132
@run_in_thread
132-
def delayed_stop(positioner, delay=1):
133+
def delayed_stop(positioner, delay=DEFAULT_DELAY):
133134
"Time-delayed stop of positioner."
134135
time.sleep(delay)
135136
positioner.stop()
@@ -223,6 +224,7 @@ def motion(rb_initial, target, rb_mid=None):
223224

224225
# force a stop now
225226
pos.stop()
227+
time.sleep(DEFAULT_DELAY)
226228
pos.cb_readback()
227229
assert pos.setpoint.get(use_monitor=False) == rb_mid
228230
assert pos.readback.get(use_monitor=False) == rb_mid

apstools/utils/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
from .statistics import xy_statistics
9797
from .statistics import factor_fwhm
9898
from .statistics import peak_full_width
99+
from .stored_dict import StoredDict
99100
from .time_constants import DAY
100101
from .time_constants import HOUR
101102
from .time_constants import MINUTE

apstools/utils/stored_dict.py

+224
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
"""
2+
Save/restore for the `RE.md` dictionary
3+
=======================================
4+
5+
To save and restore metadata between Python sessions, we provide
6+
:class:`~apstools.utils.stored_dict.StoredDict`. This support is used to save
7+
the contents of ``RE.md`` in a YAML file when this dictionary is changed.
8+
9+
.. autosummary::
10+
:nosignatures:
11+
12+
~StoredDict
13+
14+
.. rubric:: Example
15+
16+
Setup :class:`~apstools.utils.stored_dict.StoredDict()` to write the contents
17+
of ``RE.md`` to file ``".re.md_dict.yml"`` (in the present working directory):
18+
19+
.. code-block: python
20+
:linenos:
21+
22+
RE = bluesky.RunEngine()
23+
RE.md = StoredDict(".re.md_dict.yml")
24+
25+
Within seconds of changing any key in the ``RE.md`` dictionary, the entire
26+
dictionary contents will be written to the file.
27+
28+
.. note:: Only changes to the top level of `RE.md` will trigger the file
29+
to be updated. Changes to lower-level structure will not trigger updates.
30+
31+
.. note:: The YAML file is only loaded on initial use.
32+
It is not intended for :class:`~apstools.utils.stored_dict.StoredDict()` to be
33+
used to share information between multiple instances (such as two simulataneous
34+
Bluesky sessions, each writing to the same file).
35+
36+
To share the ``scan_id`` (so that it increases in a monotonic sequence)
37+
between multiple sessions, consider using an EPICS PV
38+
:class:`~apstools.devices.epics_scan_id_signal.EpicsScanIdSignal()`.
39+
40+
.. tip:: The file could be stored in the present working directory
41+
(of the bluesky session) or could be in any directory (absolute or
42+
relative) to which the session has write access.
43+
44+
.. tip:: The storage model (YAML) could be changed to something else
45+
(such as EPICS PV) by changing these two static methods:
46+
:meth:`~apstools.utils.stored_dict.StoredDict.dump()` and
47+
:meth:`~apstools.utils.stored_dict.StoredDict.load()`.
48+
"""
49+
50+
import atexit
51+
import collections.abc
52+
import datetime
53+
import json
54+
import pathlib
55+
import threading
56+
import time
57+
58+
import yaml
59+
60+
61+
class StoredDict(collections.abc.MutableMapping):
62+
"""
63+
A MutableMapping which syncs it contents to storage.
64+
65+
The contents are stored as a single YAML file.
66+
67+
When an item is *mutated* it is not written to storage immediately. The
68+
mapping is written to storage afer a 'delay' period. The 'delay' is
69+
chosen long enough to allow multiple updates to the mapping before a single
70+
write but short enough to ensure prompt backup of the mapping.
71+
72+
Example::
73+
74+
>>> import bluesky
75+
>>> RE = bluesky.RunEngine()
76+
>>> RE.md = StoredDict(".re_md_dict") # save file in pwd
77+
78+
.. rubric:: Static methods
79+
80+
All support for the YAML format is implemented in the static methods.
81+
82+
.. autosummary::
83+
84+
~dump
85+
~load
86+
87+
.. rubric:: Other public methods
88+
89+
.. autosummary::
90+
91+
~flush
92+
~popitem
93+
~reload
94+
"""
95+
96+
def __init__(self, file, delay=5, title=None, serializable=True):
97+
"""
98+
StoredDict : Dictionary that syncs to storage
99+
100+
PARAMETERS
101+
102+
file : str or pathlib.Path
103+
Name of file to store dictionary contents.
104+
delay : number
105+
Time delay (s) since last dictionary update to write to storage.
106+
Default: 5 seconds.
107+
title : str or None
108+
Comment to write at top of file.
109+
Default: "Written by StoredDict."
110+
serializable : bool
111+
If True, validate new dictionary entries are JSON serializable.
112+
"""
113+
self._file = pathlib.Path(file)
114+
self._delay = max(0, delay)
115+
self._title = title or f"Written by {self.__class__.__name__}."
116+
self.test_serializable = serializable
117+
118+
self.sync_in_progress = False
119+
self._sync_deadline = time.time()
120+
self._sync_key = f"sync_agent_{id(self):x}"
121+
self._sync_loop_period = 0.005
122+
123+
self._cache = {}
124+
self.reload()
125+
126+
# Write to storage (as needed) when process exits.
127+
atexit.register(self.flush)
128+
129+
def __delitem__(self, key):
130+
"""Delete dictionary value by key."""
131+
del self._cache[key]
132+
self._queue_storage()
133+
134+
def __getitem__(self, key):
135+
"""Get dictionary value by key."""
136+
return self._cache[key]
137+
138+
def __iter__(self):
139+
"""Iterate over the dictionary keys."""
140+
yield from self._cache
141+
142+
def __len__(self):
143+
"""Number of keys in the dictionary."""
144+
return len(self._cache)
145+
146+
def __repr__(self):
147+
"""representation of this object."""
148+
return f"<{self.__class__.__name__} {dict(self)!r}>"
149+
150+
def __setitem__(self, key, value):
151+
"""Write to the dictionary."""
152+
if self.test_serializable:
153+
json.dumps({key: value})
154+
155+
self._cache[key] = value # Store the new (or revised) content.
156+
self._queue_storage()
157+
158+
def _delayed_sync_to_storage(self):
159+
"""
160+
Sync the metadata to storage.
161+
162+
Start a time-delay thread. New writes to the metadata dictionary will
163+
extend the deadline. Sync once the deadline is reached.
164+
"""
165+
166+
def sync_agent():
167+
"""Threaded task."""
168+
self.sync_in_progress = True
169+
while time.time() < self._sync_deadline:
170+
time.sleep(self._sync_loop_period)
171+
self.sync_in_progress = False
172+
173+
StoredDict.dump(self._file, self._cache, title=self._title)
174+
175+
thred = threading.Thread(target=sync_agent)
176+
thred.start()
177+
178+
def _queue_storage(self):
179+
"""Set timer to store the revised dict."""
180+
# Reset the deadline.
181+
self._sync_deadline = time.time() + self._delay
182+
183+
if not self.sync_in_progress:
184+
# Start the sync_agent (thread).
185+
self._delayed_sync_to_storage()
186+
187+
def flush(self):
188+
"""Force a write of the dictionary to disk"""
189+
if not self.sync_in_progress:
190+
StoredDict.dump(self._file, self._cache, title=self._title)
191+
self._sync_deadline = time.time()
192+
self.sync_in_progress = False
193+
194+
def popitem(self):
195+
"""
196+
Remove and return a (key, value) pair as a 2-tuple.
197+
198+
Pairs are returned in LIFO (last-in, first-out) order.
199+
Raises KeyError if the dict is empty.
200+
"""
201+
# self._queue_storage() will be called by self.__delitem__()
202+
return self._cache.popitem()
203+
204+
def reload(self):
205+
"""Read dictionary from storage."""
206+
self._cache = StoredDict.load(self._file)
207+
208+
@staticmethod
209+
def dump(file, contents, title=None):
210+
"""Write dictionary to YAML file."""
211+
with open(file, "w") as f:
212+
if isinstance(title, str) and len(title) > 0:
213+
f.write(f"# {title}\n")
214+
f.write(f"# Dictionary contents written: {datetime.datetime.now()}\n\n")
215+
f.write(yaml.dump(contents.copy(), indent=2))
216+
217+
@staticmethod
218+
def load(file):
219+
"""Read dictionary from YAML file."""
220+
file = pathlib.Path(file)
221+
md = None
222+
if file.exists():
223+
md = yaml.load(open(file).read(), yaml.Loader)
224+
return md or {} # In case file is empty.

0 commit comments

Comments
 (0)