xrayscatteringtools 0.0.1__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.
- xrayscatteringtools/__init__.py +3 -0
- xrayscatteringtools/calib/__init__.py +0 -0
- xrayscatteringtools/calib/geometry_calibration.py +204 -0
- xrayscatteringtools/calib/scattering_corrections.py +488 -0
- xrayscatteringtools/epicsArch.py +392 -0
- xrayscatteringtools/io.py +310 -0
- xrayscatteringtools/plotting.py +88 -0
- xrayscatteringtools/theory/Compton_Factors.npy +0 -0
- xrayscatteringtools/theory/Scattering_Factors.npy +0 -0
- xrayscatteringtools/theory/__init__.py +1 -0
- xrayscatteringtools/theory/data/sf6.h5 +0 -0
- xrayscatteringtools/theory/iam.py +213 -0
- xrayscatteringtools/theory/patterns.py +36 -0
- xrayscatteringtools/utils.py +456 -0
- xrayscatteringtools-0.0.1.dist-info/METADATA +79 -0
- xrayscatteringtools-0.0.1.dist-info/RECORD +18 -0
- xrayscatteringtools-0.0.1.dist-info/WHEEL +4 -0
- xrayscatteringtools-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
import datetime
|
|
5
|
+
from dateutil.relativedelta import relativedelta
|
|
6
|
+
import urllib
|
|
7
|
+
import requests
|
|
8
|
+
import matplotlib.pyplot as plt
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
#logging.basicConfig(level=logging.DEBUG)
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
base_url = "https://pswww.slac.stanford.edu/archiveviewer"
|
|
16
|
+
retrieval_url = base_url + "/retrieval/data/getData.json"
|
|
17
|
+
mgmt_url = base_url + ":17665/mgmt/bpl/"
|
|
18
|
+
pv_arg = "?pv={}"
|
|
19
|
+
url_arg = "&{0}={1}"
|
|
20
|
+
url_flag = "&{0}"
|
|
21
|
+
date_spec_format = "{0:04}-{1:02}-{2:02}T{3:02}:{4:02}:{5:02}.{6:03}Z"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EpicsArchive(object):
|
|
25
|
+
"""
|
|
26
|
+
Class that accesses data from the new archiver.
|
|
27
|
+
Currently supports getting points, plotting points, and searching for pvs.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self):
|
|
31
|
+
self._pts_cache = None
|
|
32
|
+
self._pv_cache = None
|
|
33
|
+
|
|
34
|
+
def get_point(self, PV=None, when=None, value_only=False):
|
|
35
|
+
"""
|
|
36
|
+
Get the value from a particular point in time. Returns (timestamp, value)
|
|
37
|
+
unless value_only is true. Returns None if the archiver does not have
|
|
38
|
+
a value for that time.
|
|
39
|
+
|
|
40
|
+
when should either be a timestamp or datetime object.
|
|
41
|
+
|
|
42
|
+
This tries to be a bit smarter about detecting disconnects.
|
|
43
|
+
"""
|
|
44
|
+
when_start = to_datetime(when, "days")
|
|
45
|
+
json_start, json_end = self._json_args(when, when, "days")
|
|
46
|
+
incr = datetime.timedelta(
|
|
47
|
+
seconds=60
|
|
48
|
+
) # How far to look forward for a second data point.
|
|
49
|
+
found = False
|
|
50
|
+
now = datetime.datetime.now().astimezone(datetime.timezone.utc)
|
|
51
|
+
while not found:
|
|
52
|
+
when_end = when_start + incr
|
|
53
|
+
if when_end > now:
|
|
54
|
+
when_end = now
|
|
55
|
+
json_end = datetime_to_array(when_end)
|
|
56
|
+
json_obj = self._get_json(PV, json_start, json_end, False)
|
|
57
|
+
if len(json_obj) == 0: # Time must be before archiving!
|
|
58
|
+
return None
|
|
59
|
+
if len(json_obj[0]["data"]) > 1 or when_end >= now:
|
|
60
|
+
found = True
|
|
61
|
+
else:
|
|
62
|
+
# We only have 0-1 data points.
|
|
63
|
+
if incr.days == 0:
|
|
64
|
+
incr = 10 * incr
|
|
65
|
+
else:
|
|
66
|
+
incr = incr + datetime.timedelta(days=1)
|
|
67
|
+
data = json_obj[0]["data"]
|
|
68
|
+
# Now, data[0] is a data point archived before when. But
|
|
69
|
+
# data[1] might contain a fields/cnxlostepsecs tag that
|
|
70
|
+
# invalidates it!
|
|
71
|
+
#
|
|
72
|
+
# What if we *don't* have a second point though. Does this
|
|
73
|
+
# mean that we are *still* disconnected? Or is the data valid?
|
|
74
|
+
#
|
|
75
|
+
# For now, let's pretend that it's valid.
|
|
76
|
+
if (
|
|
77
|
+
len(data) > 1
|
|
78
|
+
and "fields" in data[1].keys()
|
|
79
|
+
and "cnxlostepsecs" in data[1]["fields"].keys()
|
|
80
|
+
and float(data[1]["fields"]["cnxlostepsecs"]) < when_start.timestamp()
|
|
81
|
+
):
|
|
82
|
+
return None
|
|
83
|
+
if value_only:
|
|
84
|
+
return data[0]["val"]
|
|
85
|
+
else:
|
|
86
|
+
return (data[0]["secs"] + data[0]["nanos"] / 1e9, data[0]["val"])
|
|
87
|
+
|
|
88
|
+
def get_points(
|
|
89
|
+
self,
|
|
90
|
+
PV=None,
|
|
91
|
+
start=30,
|
|
92
|
+
end=None,
|
|
93
|
+
unit="days",
|
|
94
|
+
chunk=False,
|
|
95
|
+
two_lists=False,
|
|
96
|
+
raw=False,
|
|
97
|
+
useMS=False,
|
|
98
|
+
):
|
|
99
|
+
"""
|
|
100
|
+
Get points from the archive, returning them as a list of tuples.
|
|
101
|
+
You may set two_lists=True to get a list of positions and a list of times instead of a single list of tuples.
|
|
102
|
+
You may set raw=True to get times as seconds from the epoch instead of as strings.
|
|
103
|
+
"""
|
|
104
|
+
if PV is None:
|
|
105
|
+
pts, _ = self._check_cache()
|
|
106
|
+
if pts is None:
|
|
107
|
+
return
|
|
108
|
+
else:
|
|
109
|
+
json_start, json_end = self._json_args(start, end, unit)
|
|
110
|
+
if valid_date_arrays(json_start, json_end):
|
|
111
|
+
json_obj = self._get_json(PV, json_start, json_end, chunk)
|
|
112
|
+
if isinstance(json_obj, int): # Handle http error code
|
|
113
|
+
logger.warning(
|
|
114
|
+
f"Not able to retrieve PV {PV} (http code {json_obj})."
|
|
115
|
+
)
|
|
116
|
+
return []
|
|
117
|
+
pts = self._json_to_pts(json_obj, useMS)
|
|
118
|
+
self._pts_cache = pts
|
|
119
|
+
self._pv_cache = PV
|
|
120
|
+
else:
|
|
121
|
+
print("Invalid dates! Start must be BEFORE end!")
|
|
122
|
+
pts = []
|
|
123
|
+
if not raw:
|
|
124
|
+
pts = pts_string_time(pts)
|
|
125
|
+
if two_lists:
|
|
126
|
+
return self._pts_to_arrays(pts)
|
|
127
|
+
else:
|
|
128
|
+
return pts
|
|
129
|
+
|
|
130
|
+
def plot_points(self, PV=None, start=5, end=None, unit="days", chunk=False):
|
|
131
|
+
"""
|
|
132
|
+
Use matplotlib to plot points from the archive.
|
|
133
|
+
"""
|
|
134
|
+
if PV is None:
|
|
135
|
+
pts, PV = self._check_cache()
|
|
136
|
+
if pts is None:
|
|
137
|
+
return
|
|
138
|
+
else:
|
|
139
|
+
pts = self.get_points(PV, start, end, unit, chunk, False, True)
|
|
140
|
+
t, x = self._pts_to_arrays(pts)
|
|
141
|
+
for i in range(len(t)):
|
|
142
|
+
t[i] = datetime.datetime.fromtimestamp(t[i])
|
|
143
|
+
fig = plt.figure()
|
|
144
|
+
fig.canvas.set_window_title("EPICS Archive " + PV)
|
|
145
|
+
plt.plot(t, x, "k.")
|
|
146
|
+
plt.step(t, x, where="post", color="k")
|
|
147
|
+
plt.ylabel(PV)
|
|
148
|
+
plt.show(block=False)
|
|
149
|
+
|
|
150
|
+
def _check_cache(self):
|
|
151
|
+
"""
|
|
152
|
+
Return cache if it exists, otherwise print a message.
|
|
153
|
+
"""
|
|
154
|
+
if self._pts_cache is None:
|
|
155
|
+
print("No cached points to use. Please choose a PV.")
|
|
156
|
+
return None, None
|
|
157
|
+
else:
|
|
158
|
+
print("Found cached data for {}".format(self._pv_cache))
|
|
159
|
+
return self._pts_cache, self._pv_cache
|
|
160
|
+
|
|
161
|
+
def search_pvs(self, glob, do_print=True):
|
|
162
|
+
"""
|
|
163
|
+
Queries the archiver with a PV search using glob format.
|
|
164
|
+
|
|
165
|
+
If do_print=True, prints the PVs nicely on your screen.
|
|
166
|
+
If do_print=False, returns the list of matches.
|
|
167
|
+
"""
|
|
168
|
+
url = mgmt_url + "getAllPVs"
|
|
169
|
+
url += pv_arg.format(urllib.parse.quote(glob, safe=""))
|
|
170
|
+
pvs = url_query(url)
|
|
171
|
+
if do_print:
|
|
172
|
+
success = list_print(pvs)
|
|
173
|
+
if not success:
|
|
174
|
+
print("No PVs found.")
|
|
175
|
+
else:
|
|
176
|
+
return pvs
|
|
177
|
+
|
|
178
|
+
def __interface(self):
|
|
179
|
+
"""
|
|
180
|
+
Arguments:
|
|
181
|
+
|
|
182
|
+
PV is the string PV to look up in the archiver. If PV is None, we'll
|
|
183
|
+
use the points found in the most recent call to get_points or
|
|
184
|
+
plot_points.
|
|
185
|
+
|
|
186
|
+
start and end represent the time period to use.
|
|
187
|
+
There are four ways to provide these time periods:
|
|
188
|
+
1. numeric arguments are relative time: e.g. start=30, unit="days"
|
|
189
|
+
begins the archiver request at 30 days ago.
|
|
190
|
+
2. Large integers (>=10000) give time since epoch.
|
|
191
|
+
3. list/tuple arguments are absolute time, in the format
|
|
192
|
+
[year, month, day, hour, min, sec], which does not need to be
|
|
193
|
+
fully specified. For example, start=[2016,1,14] end=[2016,2,14]
|
|
194
|
+
gives you the one month period between Jan 14 and Feb 14.
|
|
195
|
+
4. None gives you the endpoint. start=None is the beginning of
|
|
196
|
+
archivable data, end=None is now.
|
|
197
|
+
|
|
198
|
+
The unit argument is only used if start or end is an integer. Most
|
|
199
|
+
units of time are valid, expressed as a string, and many aliases
|
|
200
|
+
are supported.
|
|
201
|
+
"""
|
|
202
|
+
pass # This only exists to hold the common doc string elements
|
|
203
|
+
|
|
204
|
+
get_points.__doc__ += __interface.__doc__
|
|
205
|
+
plot_points.__doc__ += __interface.__doc__
|
|
206
|
+
|
|
207
|
+
def _json_args(self, start, end, unit):
|
|
208
|
+
"""
|
|
209
|
+
Change the user interface arguments into arguments suitable for the
|
|
210
|
+
URL json request.
|
|
211
|
+
"""
|
|
212
|
+
# Default endpoints
|
|
213
|
+
if start is None:
|
|
214
|
+
start = [2010]
|
|
215
|
+
if end is None:
|
|
216
|
+
end = datetime_to_array(datetime.datetime.now())
|
|
217
|
+
|
|
218
|
+
# Convert to UTC datetime
|
|
219
|
+
start = to_datetime(start, unit)
|
|
220
|
+
end = to_datetime(end, unit)
|
|
221
|
+
|
|
222
|
+
# Switch to date arrays for url construction
|
|
223
|
+
json_start = datetime_to_array(start)
|
|
224
|
+
json_end = datetime_to_array(end)
|
|
225
|
+
|
|
226
|
+
return json_start, json_end
|
|
227
|
+
|
|
228
|
+
def _get_json(self, PV, start, end, chunk):
|
|
229
|
+
"""
|
|
230
|
+
Do a url query of the new archiver. Return the json result unmodified.
|
|
231
|
+
"""
|
|
232
|
+
url = retrieval_url
|
|
233
|
+
url += pv_arg.format(PV)
|
|
234
|
+
url += url_arg.format("from", date_format(*start))
|
|
235
|
+
url += url_arg.format("to", date_format(*end))
|
|
236
|
+
if not chunk:
|
|
237
|
+
url += url_flag.format("donotchunk")
|
|
238
|
+
logger.debug(f"URL: {url}")
|
|
239
|
+
data = url_query(url)
|
|
240
|
+
return data
|
|
241
|
+
|
|
242
|
+
def _json_to_pts(self, json_obj, useMS):
|
|
243
|
+
"""
|
|
244
|
+
Interprets a data retrieval json object as an array of tuple points.
|
|
245
|
+
"""
|
|
246
|
+
return [
|
|
247
|
+
(x["secs"] + (x["nanos"] / 1e9 if useMS else 0), x["val"])
|
|
248
|
+
for x in json_obj[0]["data"]
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
def _pts_to_arrays(self, pts):
|
|
252
|
+
"""
|
|
253
|
+
Changes an array of tuples into two lists.
|
|
254
|
+
"""
|
|
255
|
+
t_array = []
|
|
256
|
+
val_array = []
|
|
257
|
+
for t, val in pts:
|
|
258
|
+
t_array.append(t)
|
|
259
|
+
val_array.append(val)
|
|
260
|
+
return t_array, val_array
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
days_map = {}
|
|
264
|
+
days_map.update({x: 365 for x in ("years", "year", "yr", "y")})
|
|
265
|
+
days_map.update({x: 365.0 / 12 for x in ("months", "month", "mon")})
|
|
266
|
+
days_map.update({x: 7 for x in ("weeks", "week", "wks", "wk", "w")})
|
|
267
|
+
days_map.update({x: 1 for x in ("days", "day", "d")})
|
|
268
|
+
days_map.update({x: 1.0 / 24 for x in ("hours", "hour", "hrs", "hr", "h")})
|
|
269
|
+
days_map.update({x: 1.0 / 24 / 60 for x in ("minutes", "mins", "min")})
|
|
270
|
+
days_map.update({x: 1.0 / 24 / 60 / 60 for x in ("seconds", "secs", "sec", "s")})
|
|
271
|
+
days_map.update({x: 1.0 / 24 / 60 / 60 / 1000 for x in ("milliseconds", "msec", "ms")})
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def url_query(url):
|
|
275
|
+
"""
|
|
276
|
+
Makes the URL request.
|
|
277
|
+
"""
|
|
278
|
+
req = requests.get(url)
|
|
279
|
+
if req.status_code != 200:
|
|
280
|
+
return req.status_code
|
|
281
|
+
return req.json()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def to_datetime(arg, unit):
|
|
285
|
+
"""
|
|
286
|
+
Convert some form of date input into a UTC datetime object.
|
|
287
|
+
"""
|
|
288
|
+
if isinstance(arg, datetime.datetime):
|
|
289
|
+
return arg.astimezone(datetime.timezone.utc)
|
|
290
|
+
if isinstance(arg, (int, float)):
|
|
291
|
+
if arg < 10000:
|
|
292
|
+
return datetime_ago(arg, unit)
|
|
293
|
+
else: # big - must be time in secs since epoch.
|
|
294
|
+
return datetime.datetime.fromtimestamp(int(arg)).astimezone(
|
|
295
|
+
datetime.timezone.utc
|
|
296
|
+
)
|
|
297
|
+
if isinstance(arg, (list, tuple)):
|
|
298
|
+
arg = list(arg)
|
|
299
|
+
while len(arg) < 3:
|
|
300
|
+
arg.append(0)
|
|
301
|
+
return datetime.datetime(*arg).astimezone(datetime.timezone.utc)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def datetime_ago(delta, unit):
|
|
305
|
+
"""
|
|
306
|
+
Return datetime object at the time of delta units ago.
|
|
307
|
+
"""
|
|
308
|
+
days = delta * days_map[unit]
|
|
309
|
+
t = datetime.datetime.now() - datetime.timedelta(days)
|
|
310
|
+
return t.astimezone(datetime.timezone.utc)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def datetime_to_array(dt):
|
|
314
|
+
"""
|
|
315
|
+
Convert UTC datetime object to an array that can be passed to date_format.
|
|
316
|
+
"""
|
|
317
|
+
return [dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, dt.microsecond]
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def date_format(year=2015, month=1, day=1, hr=0, min=0, s=0, ms=0):
|
|
321
|
+
"""Convert date/time parameters to date format string for archiver"""
|
|
322
|
+
d = date_spec_format.format(year, month, day, hr, min, s, ms)
|
|
323
|
+
return d
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def valid_date_arrays(start, end):
|
|
327
|
+
"""
|
|
328
|
+
Checks if start is temporally before end.
|
|
329
|
+
"""
|
|
330
|
+
start_copy, end_copy = list(start), list(end)
|
|
331
|
+
for arg in (start_copy, end_copy):
|
|
332
|
+
while len(arg) < 3:
|
|
333
|
+
arg.append(1)
|
|
334
|
+
time_delta = datetime.datetime(*end_copy) - datetime.datetime(*start_copy)
|
|
335
|
+
if time_delta > datetime.timedelta(0):
|
|
336
|
+
return True
|
|
337
|
+
return False
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def pts_string_time(pts):
|
|
341
|
+
"""
|
|
342
|
+
Convert array of points of the form (unix timestamp, value) to the form
|
|
343
|
+
(ctime string, value)
|
|
344
|
+
"""
|
|
345
|
+
newpts = []
|
|
346
|
+
for i in range(len(pts)):
|
|
347
|
+
newpts.append((time.ctime(pts[i][0]), pts[i][1]))
|
|
348
|
+
return newpts
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def list_print(data):
|
|
352
|
+
"""
|
|
353
|
+
Prints list data in columns as you'd expect from a terminal.
|
|
354
|
+
"""
|
|
355
|
+
if len(data) == 0:
|
|
356
|
+
return False
|
|
357
|
+
text_data = [str(i) for i in data]
|
|
358
|
+
col_width = max([len(i) for i in text_data]) + 2
|
|
359
|
+
_, term_width = os.popen("stty size", "r").read().split()
|
|
360
|
+
n_cols = int(term_width) / col_width
|
|
361
|
+
n_text = len(text_data)
|
|
362
|
+
n_full_col = int(math.ceil(float(n_text) / n_cols))
|
|
363
|
+
text_rows = []
|
|
364
|
+
for i in range(n_full_col):
|
|
365
|
+
text_rows.append([])
|
|
366
|
+
row = 0
|
|
367
|
+
for text in text_data:
|
|
368
|
+
text_rows[row].append(text)
|
|
369
|
+
if row < n_full_col - 1:
|
|
370
|
+
row += 1
|
|
371
|
+
else:
|
|
372
|
+
row = 0
|
|
373
|
+
line_elem = "{0:{1}}"
|
|
374
|
+
for row in text_rows:
|
|
375
|
+
line = ""
|
|
376
|
+
for text in row:
|
|
377
|
+
line += line_elem.format(text, col_width)
|
|
378
|
+
print(line)
|
|
379
|
+
return True
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def ts_to_datetime(ts):
|
|
383
|
+
"""
|
|
384
|
+
The LCLS timestamp is using the EPICS epoch, which is
|
|
385
|
+
20 years after the POSIX epoch.
|
|
386
|
+
"""
|
|
387
|
+
sec = ts >> 32 & 0xFFFFFFFF
|
|
388
|
+
nsec = nsec = ts & 0xFFFFFFFF
|
|
389
|
+
dt = datetime.datetime.fromtimestamp(sec + nsec * 1e-9)
|
|
390
|
+
dt += relativedelta(years=20)
|
|
391
|
+
return dt
|
|
392
|
+
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import h5py
|
|
3
|
+
from tqdm.auto import tqdm
|
|
4
|
+
from xrayscatteringtools.epicsArch import EpicsArchive
|
|
5
|
+
from scipy.interpolate import interp1d
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
def combineRuns(runNumbers, folders, keys_to_combine, keys_to_sum, keys_to_check, verbose=False, archImport=False):
|
|
9
|
+
"""
|
|
10
|
+
Combine data from multiple experimental runs into a single consolidated dataset.
|
|
11
|
+
|
|
12
|
+
This function loads data from HDF5 files corresponding to each run, concatenates or sums
|
|
13
|
+
selected keys, checks consistency of other keys, and optionally fills missing EPICS
|
|
14
|
+
gas cell pressure data from an archive. Each run's data can reside in a separate folder
|
|
15
|
+
or in a single common folder.
|
|
16
|
+
|
|
17
|
+
Parameters
|
|
18
|
+
----------
|
|
19
|
+
runNumbers : int or list of int
|
|
20
|
+
Run number(s) to load and combine. Can be a single integer or a list of integers.
|
|
21
|
+
folders : str, bytes, list, or tuple
|
|
22
|
+
Path(s) to the folder(s) containing the data files. If a single string is provided,
|
|
23
|
+
it is repeated for all run numbers. If multiple folders are provided, the number of
|
|
24
|
+
folders must match the number of run numbers.
|
|
25
|
+
keys_to_combine : list of str
|
|
26
|
+
Keys in the data files whose arrays should be concatenated along the first axis.
|
|
27
|
+
keys_to_sum : list of str
|
|
28
|
+
Keys in the data files whose arrays should be summed element-wise across runs.
|
|
29
|
+
keys_to_check : list of str
|
|
30
|
+
Keys for which consistency across runs should be verified. If any discrepancies
|
|
31
|
+
are found, a warning is printed.
|
|
32
|
+
verbose : bool, optional
|
|
33
|
+
If True, prints detailed information during data loading (default: False).
|
|
34
|
+
archImport : bool, optional
|
|
35
|
+
If True, enables special handling of missing gas cell pressure data from older
|
|
36
|
+
archive files (default: False).
|
|
37
|
+
|
|
38
|
+
Returns
|
|
39
|
+
-------
|
|
40
|
+
data_combined : dict
|
|
41
|
+
Dictionary containing the combined data from all runs. Keys include:
|
|
42
|
+
- Concatenated keys from `keys_to_combine`
|
|
43
|
+
- Summed keys from `keys_to_sum`
|
|
44
|
+
- Checked keys from `keys_to_check`
|
|
45
|
+
- `'run_indicator'`: an array indicating which run each data point belongs to
|
|
46
|
+
- `'epicsUser/gasCell_pressure'`: filled either from files or from the EPICS archive
|
|
47
|
+
if `archImport` is True and the key was missing.
|
|
48
|
+
|
|
49
|
+
Raises
|
|
50
|
+
------
|
|
51
|
+
TypeError
|
|
52
|
+
If `folders` is not a string, bytes, list, or tuple.
|
|
53
|
+
ValueError
|
|
54
|
+
If multiple folders are provided but the number of folders does not match
|
|
55
|
+
the number of run numbers.
|
|
56
|
+
"""
|
|
57
|
+
# Ensure runNumbers is a list
|
|
58
|
+
if not isinstance(runNumbers, (list, tuple)):
|
|
59
|
+
runNumbers = [runNumbers]
|
|
60
|
+
|
|
61
|
+
# If folders is a string or bytes, make it a list of one element
|
|
62
|
+
if isinstance(folders, (str, bytes)):
|
|
63
|
+
folders = [folders]
|
|
64
|
+
# If folders is a tuple, convert to list
|
|
65
|
+
elif isinstance(folders, tuple):
|
|
66
|
+
folders = list(folders)
|
|
67
|
+
# If folders is already a list, keep as is
|
|
68
|
+
elif not isinstance(folders, list):
|
|
69
|
+
raise TypeError(f"'folders' must be a string, bytes, list, or tuple. Got {type(folders)}")
|
|
70
|
+
|
|
71
|
+
if len(folders) > 1:
|
|
72
|
+
if len(folders) != len(runNumbers):
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"If 'folders' has more than one element, its length must match 'runNumbers'. "
|
|
75
|
+
f"Got len(runNumbers)={len(runNumbers)} and len(folders)={len(folders)}."
|
|
76
|
+
)
|
|
77
|
+
else:
|
|
78
|
+
# Repeat the single folders to match the length of runNumbers
|
|
79
|
+
folders = folders * len(runNumbers)
|
|
80
|
+
|
|
81
|
+
data_array = []
|
|
82
|
+
for i,runNumber in enumerate(tqdm(runNumbers,desc="Loading Runs")):
|
|
83
|
+
data = {}
|
|
84
|
+
experiment = folders[i].split('/')[6]
|
|
85
|
+
filename = f'{folders[i]}{experiment}_Run{runNumToString(runNumber)}.h5'
|
|
86
|
+
print('Loading: ' + filename)
|
|
87
|
+
with h5py.File(filename,'r') as f:
|
|
88
|
+
get_leaves(f,data,verbose=verbose)
|
|
89
|
+
data_array.append(data)
|
|
90
|
+
data_combined = {}
|
|
91
|
+
for key in keys_to_combine:
|
|
92
|
+
# Special routine for loading the gas cell pressure if it was not saved. Likely a better way to do this... should talk to silke
|
|
93
|
+
epicsLoad = False # Default flag value
|
|
94
|
+
if (key == 'epicsUser/gasCell_pressure') & (archImport):
|
|
95
|
+
try:
|
|
96
|
+
arr = np.squeeze(data_array[0][key])
|
|
97
|
+
for data in data_array[1:]:
|
|
98
|
+
arr = np.concatenate((arr,np.squeeze(data[key])),axis=0)
|
|
99
|
+
data_combined[key] = arr
|
|
100
|
+
except:
|
|
101
|
+
epicsLoad = True # Set flag if we can't load from the files
|
|
102
|
+
else: # All other keys load normally
|
|
103
|
+
arr = np.squeeze(data_array[0][key])
|
|
104
|
+
for data in data_array[1:]:
|
|
105
|
+
arr = np.concatenate((arr,np.squeeze(data[key])),axis=0)
|
|
106
|
+
data_combined[key] = arr
|
|
107
|
+
run_indicator = np.array([])
|
|
108
|
+
for i,runNumber in enumerate(runNumbers):
|
|
109
|
+
run_indicator = np.concatenate((run_indicator,runNumber*np.ones_like(data_array[i]['lightStatus/xray'])))
|
|
110
|
+
data_combined['run_indicator'] = run_indicator
|
|
111
|
+
for key in keys_to_sum:
|
|
112
|
+
arr = np.zeros_like(data_array[0][key])
|
|
113
|
+
for data in data_array:
|
|
114
|
+
arr += data[key]
|
|
115
|
+
data_combined[key] = arr
|
|
116
|
+
for key in keys_to_check:
|
|
117
|
+
arr = data_array[0][key]
|
|
118
|
+
for i,data in enumerate(data_array):
|
|
119
|
+
if not np.array_equal(data[key],arr):
|
|
120
|
+
print(f'Problem with key {key} in run {runNumbers[i]}')
|
|
121
|
+
data_combined[key] = arr
|
|
122
|
+
# Now to do the special gas cell pressure loading if the flag was set
|
|
123
|
+
if epicsLoad:
|
|
124
|
+
archive = EpicsArchive()
|
|
125
|
+
unixTime = data_combined['unixTime']
|
|
126
|
+
epicsPressure = np.array([]) # Init empty array
|
|
127
|
+
for i,runNumber in enumerate(runNumbers):
|
|
128
|
+
# Pull out start and end times from each run
|
|
129
|
+
runUnixTime = unixTime[run_indicator==runNumber]
|
|
130
|
+
startTime = runUnixTime[0]
|
|
131
|
+
endTime = runUnixTime[-1]
|
|
132
|
+
[times,pressure] = archive.get_points(PV='CXI:MKS670:READINGGET', start=startTime, end=endTime,unit="seconds",raw=True,two_lists=True); # Make Request
|
|
133
|
+
# Interpolate the data
|
|
134
|
+
interp_func = interp1d(times, pressure, kind='previous', fill_value='extrapolate')
|
|
135
|
+
epicsPressure = np.append(epicsPressure,interp_func(runUnixTime)) # Append the data
|
|
136
|
+
# Once all the data is loaded in
|
|
137
|
+
data_combined['epicsUser/gasCell_pressure'] = epicsPressure # Save to the original key.
|
|
138
|
+
print('Loaded Data')
|
|
139
|
+
return data_combined
|
|
140
|
+
|
|
141
|
+
def get_tree(f):
|
|
142
|
+
"""List the full tree of the HDF5 file.
|
|
143
|
+
|
|
144
|
+
Parameters
|
|
145
|
+
----------
|
|
146
|
+
f : h5py.File
|
|
147
|
+
The HDF5 file object to traverse.
|
|
148
|
+
|
|
149
|
+
Returns
|
|
150
|
+
-------
|
|
151
|
+
None
|
|
152
|
+
Prints the structure of the HDF5 file.
|
|
153
|
+
"""
|
|
154
|
+
def printname(name):
|
|
155
|
+
print(name, type(f[name]))
|
|
156
|
+
f.visit(printname)
|
|
157
|
+
|
|
158
|
+
def is_leaf(dataset):
|
|
159
|
+
"""Check if an HDF5 node is a dataset (leaf node).
|
|
160
|
+
|
|
161
|
+
Parameters
|
|
162
|
+
----------
|
|
163
|
+
dataset : h5py.Dataset or h5py.Group
|
|
164
|
+
The HDF5 node to check.
|
|
165
|
+
|
|
166
|
+
Returns
|
|
167
|
+
-------
|
|
168
|
+
bool
|
|
169
|
+
True if the node is a dataset, False otherwise.
|
|
170
|
+
"""
|
|
171
|
+
return isinstance(dataset, h5py.Dataset)
|
|
172
|
+
|
|
173
|
+
def get_leaves(f, saveto, verbose=False):
|
|
174
|
+
"""Retrieve all leaf datasets from an HDF5 file and save them to a dictionary.
|
|
175
|
+
|
|
176
|
+
Parameters
|
|
177
|
+
----------
|
|
178
|
+
f : h5py.File
|
|
179
|
+
The HDF5 file object to traverse.
|
|
180
|
+
saveto : dict
|
|
181
|
+
Dictionary to store the retrieved datasets.
|
|
182
|
+
verbose : bool, optional
|
|
183
|
+
If True, print detailed information about each dataset (default: False).
|
|
184
|
+
|
|
185
|
+
Returns
|
|
186
|
+
-------
|
|
187
|
+
None
|
|
188
|
+
The datasets are stored in the provided dictionary.
|
|
189
|
+
"""
|
|
190
|
+
def return_leaf(name):
|
|
191
|
+
if is_leaf(f[name]):
|
|
192
|
+
if verbose:
|
|
193
|
+
print(name, f[name][()].shape)
|
|
194
|
+
saveto[name] = f[name][()]
|
|
195
|
+
f.visit(return_leaf)
|
|
196
|
+
|
|
197
|
+
def get_data_paths(run_numbers, config_path='config.yaml'):
|
|
198
|
+
"""
|
|
199
|
+
Retrieve data directories for a list of run numbers.
|
|
200
|
+
|
|
201
|
+
Reads a YAML configuration file specifying run ranges and associated
|
|
202
|
+
data paths, returning the paths corresponding to the provided run numbers.
|
|
203
|
+
|
|
204
|
+
Parameters
|
|
205
|
+
----------
|
|
206
|
+
run_numbers : int or iterable of int
|
|
207
|
+
Run number(s) for which the data paths are requested.
|
|
208
|
+
config_path : str, optional
|
|
209
|
+
Path to the YAML configuration file (default is 'config.yaml').
|
|
210
|
+
|
|
211
|
+
Returns
|
|
212
|
+
-------
|
|
213
|
+
list of str
|
|
214
|
+
Data directory paths corresponding to each run number.
|
|
215
|
+
|
|
216
|
+
Raises
|
|
217
|
+
------
|
|
218
|
+
ValueError
|
|
219
|
+
If any run number does not have a corresponding data path.
|
|
220
|
+
FileNotFoundError
|
|
221
|
+
If the configuration file does not exist.
|
|
222
|
+
yaml.YAMLError
|
|
223
|
+
If there is an error parsing the YAML configuration file.
|
|
224
|
+
|
|
225
|
+
Examples
|
|
226
|
+
--------
|
|
227
|
+
>>> get_data_paths([5, 15, 25], 'config.yaml')
|
|
228
|
+
['/sdf/data/lcls/ds/cxi/cxil', '/sdf/data/lcls/ds/cxi/cxil2', '/sdf/data/lcls/ds/cxi/cxil3']
|
|
229
|
+
"""
|
|
230
|
+
# Ensure run_numbers is iterable
|
|
231
|
+
if isinstance(run_numbers, int):
|
|
232
|
+
run_numbers = [run_numbers]
|
|
233
|
+
|
|
234
|
+
with open(config_path) as f:
|
|
235
|
+
config = yaml.safe_load(f)
|
|
236
|
+
|
|
237
|
+
paths = []
|
|
238
|
+
for run in run_numbers:
|
|
239
|
+
for entry in config['data_paths']:
|
|
240
|
+
lower, upper = entry['runs']
|
|
241
|
+
if lower <= run <= upper:
|
|
242
|
+
paths.append(entry['path'])
|
|
243
|
+
break
|
|
244
|
+
else:
|
|
245
|
+
raise ValueError(f"No data path found for run number: {run}")
|
|
246
|
+
|
|
247
|
+
return paths
|
|
248
|
+
|
|
249
|
+
def runNumToString(num):
|
|
250
|
+
"""Convert a run number to a zero-padded string of length 4.
|
|
251
|
+
|
|
252
|
+
Parameters
|
|
253
|
+
----------
|
|
254
|
+
num : int
|
|
255
|
+
The run number to convert.
|
|
256
|
+
|
|
257
|
+
Returns
|
|
258
|
+
-------
|
|
259
|
+
numstr : str
|
|
260
|
+
The zero-padded string representation of the run number.
|
|
261
|
+
"""
|
|
262
|
+
numstr = str(num)
|
|
263
|
+
while len(numstr) < 4:
|
|
264
|
+
numstr = '0' + numstr
|
|
265
|
+
return numstr
|
|
266
|
+
|
|
267
|
+
def read_xyz(filename):
|
|
268
|
+
"""
|
|
269
|
+
Read a molecular structure from an XYZ file.
|
|
270
|
+
|
|
271
|
+
Parameters
|
|
272
|
+
----------
|
|
273
|
+
filename : str
|
|
274
|
+
Path to the XYZ file to be read.
|
|
275
|
+
|
|
276
|
+
Returns
|
|
277
|
+
-------
|
|
278
|
+
num_atoms : int
|
|
279
|
+
Number of atoms in the XYZ file.
|
|
280
|
+
comment : str
|
|
281
|
+
Comment line from the XYZ file (usually contains metadata or description).
|
|
282
|
+
atoms : list of str
|
|
283
|
+
List of atomic symbols (e.g., 'C', 'H', 'O') for each atom.
|
|
284
|
+
coords : list of tuple of float
|
|
285
|
+
Cartesian coordinates of each atom as (x, y, z) in the same units as the file.
|
|
286
|
+
|
|
287
|
+
Notes
|
|
288
|
+
-----
|
|
289
|
+
The XYZ file format is expected to have:
|
|
290
|
+
1. First line: number of atoms.
|
|
291
|
+
2. Second line: comment or description.
|
|
292
|
+
3. Following lines: atomic symbol and x, y, z coordinates for each atom.
|
|
293
|
+
"""
|
|
294
|
+
with open(filename, 'r') as f:
|
|
295
|
+
lines = f.readlines()
|
|
296
|
+
|
|
297
|
+
num_atoms = int(lines[0].strip())
|
|
298
|
+
comment = lines[1].strip()
|
|
299
|
+
|
|
300
|
+
atoms = []
|
|
301
|
+
coords = []
|
|
302
|
+
for line in lines[2:2 + num_atoms]:
|
|
303
|
+
parts = line.split()
|
|
304
|
+
element = parts[0]
|
|
305
|
+
x, y, z = map(float, parts[1:4])
|
|
306
|
+
atoms.append(element)
|
|
307
|
+
coords.append((x, y, z))
|
|
308
|
+
|
|
309
|
+
return num_atoms, comment, atoms, coords
|
|
310
|
+
|