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.
@@ -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
+