skutils 0.1.0a0__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,1068 @@
1
+ import requests
2
+ import urllib.request
3
+ import os
4
+ from typing import Literal, Union, Optional, Sequence, Any, Dict, List
5
+
6
+
7
+ class FemtoDAQController:
8
+ """
9
+ A controller for FemtoDAQ devices, such as the FemtoDAQ Kingfisher or FemtoDAQ Vireo.
10
+ For full functionality, use the FemtoDAQController with a v6 DDC_APPs version for your FemtoDAQ device.
11
+ """
12
+
13
+ def __init__(self, url: str, verbose: bool = False):
14
+ """Initialize the FemtoDAQController to use a specified URL as its target FemtoDAQ device
15
+
16
+ :param url: The local URL of the FemtoDAQ device.
17
+ :param verbose: whether you want the FemtoDAQController to print things at you.
18
+ """
19
+ assert isinstance(url, str), "url must be a string"
20
+ if url.startswith("https://"):
21
+ raise ValueError(
22
+ "Secure http not supported! (digitizer url must begin with 'http' not 'https')"
23
+ )
24
+ # add http:// manually if not provided
25
+ elif not url.startswith("http://"):
26
+ url = f"http://{url}"
27
+
28
+ if url[-1] == "/":
29
+ url = url[:-1]
30
+ self.url = url
31
+ self.verbose = verbose
32
+ self.fpga_data = self.__get_fpga_data()
33
+
34
+ # _________________________________________________________________________
35
+ def __get_fpga_data(self):
36
+ route = self.url + "/rest/fpga_data"
37
+ resp = requests.get(route, json={})
38
+ if not resp.ok:
39
+ print(f"unable to connect with FemtoDAQ device at {self.url}!")
40
+ exit()
41
+ return resp.json()["data"]
42
+
43
+ # _________________________________________________________________________
44
+ def __config_url(self, channel: Union[str, int], setting: str):
45
+ # ensure the channel is an acceptable range
46
+ if channel == "global":
47
+ pass
48
+ elif channel not in self.channels:
49
+ raise ValueError(
50
+ f"Invalid Channel. Must be 'global' or integer between 0-{self.fpga_data['num_channels'] - 1}"
51
+ )
52
+
53
+ route = self.url + f"/rest/config/{channel}/{setting}"
54
+ return route
55
+
56
+ # _________________________________________________________________________
57
+ def __runtime_url(self, command: str):
58
+ route = self.url + f"/rest/runtime/{command}"
59
+ return route
60
+
61
+ # _________________________________________________________________________
62
+ def __gui_url(self, command: str):
63
+ route = self.url + f"/web_API/{command}"
64
+ return route
65
+
66
+ # _________________________________________________________________________
67
+ def __download_url(self, filename: str):
68
+ route = self.url + f"/DOWNLOAD_FILES_FROM_DIGITIZER/{filename}"
69
+ return route
70
+
71
+ # _________________________________________________________________________
72
+ def __get(self, route: str, data: Dict[str, Any] = {}) -> Dict[str, Any]:
73
+ if self.verbose:
74
+ print(f"sending GET to {route} with payload {data}")
75
+ resp = requests.get(route, json=data)
76
+ if not resp.ok:
77
+ print(f"Request to {route} failed due to {resp.status_code}:{resp.reason}")
78
+
79
+ resp_json = resp.json()
80
+ status = resp_json.get("status", "").upper()
81
+
82
+ if status == "SUCCESS":
83
+ pass
84
+ elif status == "ERROR":
85
+ raise RuntimeError(
86
+ f"Vireo connection returned an error with message {resp_json['message']}"
87
+ )
88
+ elif status == "NOT SUPPORTED":
89
+ print(
90
+ f"Feature of {route} not supported on the specific unit and has been ignored."
91
+ )
92
+ elif status == "WARNING":
93
+ print(f"Vireo connection has sent back a warning! {resp_json['message']}")
94
+ elif status == "CRITICAL":
95
+ raise RuntimeError(
96
+ f"Vireo connection has returned a CRITICAL ERROR: {resp_json['message']}"
97
+ )
98
+ elif status == "FAILURE":
99
+ print(
100
+ "Vireo connection indicated FAILURE, this is an old error code for non-updated REST endpoints, so is treated as a warning"
101
+ + f", this may be insufficient: {resp_json['message']}"
102
+ )
103
+ return resp_json
104
+
105
+ # _________________________________________________________________________
106
+ def __post(self, route: str, data: Dict[Any, Any] = {}, print_failure: bool = True):
107
+ if self.verbose:
108
+ print(f"sending POST to {route} with payload {data}")
109
+ resp = requests.post(route, json=data)
110
+ if not resp.ok:
111
+ print(f"Request to {route} failed due to {resp.status_code}:{resp.reason}")
112
+ raise requests.ConnectionError(
113
+ f"Request to {route} failed due to {resp.status_code}:{resp.reason}"
114
+ )
115
+
116
+ resp_json = resp.json()
117
+ status = resp_json.get("status", "").upper()
118
+
119
+ if status == "SUCCESS":
120
+ pass
121
+ elif status == "ERROR":
122
+ raise RuntimeError(
123
+ f"Vireo connection returned an error with message {resp_json['message']}"
124
+ )
125
+ elif status == "NOT SUPPORTED":
126
+ print(
127
+ f"Feature of {route} not supported on the specific unit and has been ignored."
128
+ )
129
+ elif status == "WARNING":
130
+ print(f"Vireo connection has sent back a warning! {resp_json['message']}")
131
+ elif status == "CRITICAL":
132
+ raise RuntimeError(
133
+ f"Vireo connection has returned a CRITICAL ERROR: {resp_json['message']}"
134
+ )
135
+ elif status == "FAILURE":
136
+ print(
137
+ "Vireo connection indicated FAILURE, this is an old error code for non-updated REST endpoints, so is treated as a warning"
138
+ + f", this may be insufficient: {resp_json['message']}"
139
+ )
140
+ return resp_json
141
+
142
+ # #########################################################################
143
+ # Global Settings
144
+ # #########################################################################
145
+
146
+ # _________________________________________________________________________
147
+ # TriggerXPosition
148
+ def getTriggerXPosition(self):
149
+ """
150
+ Get the position of where the triggered item will be located across the N-sample window
151
+ """
152
+ route = self.__config_url("global", "TriggerXPosition")
153
+ resp = self.__get(route)
154
+ return resp["data"]
155
+
156
+ def setTriggerXPosition(self, x_position: int):
157
+ """
158
+ Set the position of the trigger in the N-sample window.
159
+
160
+ :param x_position: The position of the trigger in the N-sample window.
161
+ """
162
+ route = self.__config_url("global", "TriggerXPosition")
163
+ data = {"x_position": x_position}
164
+ self.__post(route, data)
165
+
166
+ # _________________________________________________________________________
167
+ # TriggerActiveWindow
168
+ def getTriggerActiveWindow(self):
169
+ """
170
+ Get the duration of the time window when the instrument is counting triggers occuring on all enabled ADC channels
171
+ """
172
+ route = self.__config_url("global", "TriggerActiveWindow")
173
+ resp = self.__get(route)
174
+ return resp["data"]
175
+
176
+ def setTriggerActiveWindow(self, window_width: int):
177
+ """
178
+ Set the duration of the time window when the instrument is counting triggers occuring on all enabled ADC channels.
179
+ For example, when dealing with HPGe detectors, the Trigger Active Window should cover the
180
+ duration of the energy shaping filters. Additional trigger pulses in an ADC channel with signal a
181
+ signal pileup. These events should be excluded from the pulse height histogram.
182
+
183
+ In waveform capture mode, the waveform will be truncated to the size of "Trigger Active Window".
184
+
185
+ If your events are short and do not need to use the entire 8192 sample window, you can reduce
186
+ your file size and increase your event throughput.
187
+
188
+ :param window_width: number of samples to keep the trigger active.
189
+ """
190
+ route = self.__config_url("global", "TriggerActiveWindow")
191
+ data = {"window_width": window_width}
192
+ self.__post(route, data)
193
+
194
+ # _________________________________________________________________________
195
+ # PulseHeightWindow
196
+ def getPulseHeightWindow(self):
197
+ """
198
+ Get the window to check the maximum value of pulses in from the trigger
199
+ """
200
+ route = self.__config_url("global", "PulseHeightWindow")
201
+ resp = self.__get(route)
202
+ return resp["data"]
203
+
204
+ def setPulseHeightWindow(self, window_width: int):
205
+ """
206
+ Set the active window for measuring pulse heights compared to the trigger
207
+
208
+ :param window_width: Number of ADC samples after the trigger to look for maximum pulse height values
209
+ """
210
+ route = self.__config_url("global", "PulseHeightWindow")
211
+ data = {"window_width": window_width}
212
+ self.__post(route, data)
213
+
214
+ # _________________________________________________________________________
215
+ # EnableBaselineRestoration
216
+ def getEnableBaselineRestoration(self):
217
+ """
218
+ Get the status of enablement of the Baseline Restoration feature, Baseline Restoration is not supported on all products
219
+ """
220
+ route = self.__config_url("global", "EnableBaselineRestoration")
221
+ resp = self.__get(route)
222
+ return resp["data"]
223
+
224
+ def setEnableBaselineRestoration(self, enable: bool):
225
+ """
226
+ Enable (or disable) Baseline Restoration on some products, Baseline Restoration is not supported on all products
227
+ """
228
+ route = self.__config_url("global", "EnableBaselineRestoration")
229
+ data = {"enable": enable}
230
+ self.__post(route, data)
231
+
232
+ # _________________________________________________________________________
233
+ # BaselineRestorationExclusion
234
+ def getBaselineRestorationExclusion(self):
235
+ """
236
+ Get the area used in baseline restoration exclusion to excluse your triggered pulse.
237
+ """
238
+ route = self.__config_url("global", "BaselineRestorationExclusion")
239
+ resp = self.__get(route)
240
+ return resp["data"]
241
+
242
+ def setBaselineRestorationExclusion(self, window_width: int):
243
+ """
244
+ Set the area used to exclude an area from being affected from baseline restoration
245
+
246
+ :param window_width: area to prevent affection from restoration exclusion.
247
+ """
248
+ route = self.__config_url("global", "BaselineRestorationExclusion")
249
+ data = {"window_width": window_width}
250
+ self.__post(route, data)
251
+
252
+ # _________________________________________________________________________
253
+ # PulseHeightAveragingWindow
254
+ def getPulseHeightAveragingWindow(self):
255
+ """
256
+ Get how many pulseheights are averaged together for performing a trigger
257
+ """
258
+ route = self.__config_url("global", "PulseHeightAveragingWindow")
259
+ resp = self.__get(route)
260
+ return resp["data"]
261
+
262
+ def setPulseHeightAveragingWindow(self, window_width: int):
263
+ """
264
+ Set how many pulseheights are averaged together for performing a trigger
265
+
266
+ :param window_width: integer width in ADC samples
267
+ """
268
+ route = self.__config_url("global", "PulseHeightAveragingWindow")
269
+ data = {"window_width": window_width}
270
+ self.__post(route, data)
271
+
272
+ # _________________________________________________________________________
273
+ # QuadQDCWindows
274
+ def getQuadQDCWindows(self):
275
+ """
276
+ Get the quad QDC integration windows.
277
+ """
278
+ route = self.__config_url("global", "QuadQDCWindows")
279
+ resp = self.__get(route)
280
+ return (
281
+ resp["data"]["base_width"],
282
+ resp["data"]["fast_width"],
283
+ resp["data"]["slow_width"],
284
+ resp["data"]["tail_width"],
285
+ )
286
+
287
+ def setQuadQDCWindows(
288
+ self, base_width: int, fast_width: int, slow_width: int, tail_width: int
289
+ ):
290
+ """
291
+ Set the windows for FGPA-based integration of an event
292
+
293
+ :param base_width: pre-trigger area to integrate in ADC count, this is not configurable, but it is 128 samples wide and ends 8 samples before the FAST window
294
+ :param fast_width: Width of the fast window, starts at hte maximum value of the sliding window integration and will always cover the peak of the pulse.
295
+ :param slow_width: starts at the end of the fast window, integer in ADC counts.
296
+ :param tail_width: Starts at the end of the slow window, integer in ADC counts.
297
+
298
+ """
299
+ route = self.__config_url("global", "QuadQDCWindows")
300
+ data = {
301
+ "base_width": base_width,
302
+ "fast_width": fast_width,
303
+ "slow_width": slow_width,
304
+ "tail_width": tail_width,
305
+ }
306
+ self.__post(route, data)
307
+
308
+ # _________________________________________________________________________
309
+ # BiasVoltage
310
+ def getBiasVoltage(self):
311
+ """
312
+ Read back the voltage offset for a detector
313
+ """
314
+ route = self.__config_url("global", "BiasVoltage")
315
+ resp = self.__get(route)
316
+ return resp["data"]
317
+
318
+ def setBiasVoltage(self, voltage: float):
319
+ """
320
+ Set the intended voltage offset for biasing a detector.
321
+
322
+ :param voltage: An integer indicating the voltage in volts to offset the HV output bias.
323
+ """
324
+ route = self.__config_url("global", "BiasVoltage")
325
+ data = {"voltage": voltage}
326
+ self.__post(route, data)
327
+
328
+ # _________________________________________________________________________
329
+ # BiasVoltageRaw
330
+ def getBiasVoltageRaw(self):
331
+ """
332
+ Get the raw DAC value used to bias a detector
333
+ """
334
+ route = self.__config_url("global", "BiasVoltageRaw")
335
+ resp = self.__get(route)
336
+ return resp["data"]
337
+
338
+ def setBiasVoltageRaw(self, voltage: int):
339
+ """
340
+ Set the raw DAC value used to bias output for a detector.
341
+
342
+ :param voltage: an integer indicating the voltage in raw DAC bytes
343
+ """
344
+ route = self.__config_url("global", "BiasVoltageRaw")
345
+ data = {"dac_value": voltage}
346
+ self.__post(route, data)
347
+
348
+ # _________________________________________________________________________
349
+ # GlobalID (aka module_number)
350
+ # _________________________________________________________________________
351
+ def getGlobalId(self):
352
+ """
353
+ Get the assigned global ID for an experiment to the device.
354
+ """
355
+ route = self.__config_url("global", "GlobalID")
356
+ resp = self.__get(route)
357
+ return resp["data"]
358
+
359
+ def setGlobalId(self, global_id: int):
360
+ """
361
+ Set the globalID for an experiment to the device.
362
+
363
+ :param global_id: a 0-255 integer representing an ID in an experiment
364
+ """
365
+ assert isinstance(global_id, int), "global id must be an integer between 0-255"
366
+ assert (global_id >= 0) and (global_id <= 255), (
367
+ "global id must be an integer between 0-255"
368
+ )
369
+ route = self.__config_url("global", "GlobalID")
370
+ data = {"global_id": global_id}
371
+ self.__post(route, data)
372
+
373
+ def getStreamFormatInfo(self):
374
+ """returns the output of `utils.get_formats` from the target unit"""
375
+ return self.__get(self.url + "/rest/data/SoftwareStreamingFormats")["data"]
376
+
377
+ def getRecordingFormatInfo(self):
378
+ """returns the output of `utils.get_streaming_formats` from the target unit"""
379
+ return self.__get(self.url + "/rest/data/RecordingFormats")["data"]
380
+
381
+ def getSoftwareVersion(self):
382
+ """Returns the Vireo Software version"""
383
+ return self.__get(self.url + "/rest/data/SoftwareVersion")["data"]
384
+
385
+ def getFirmwareVersion(self):
386
+ """Returns the Vireo Firmware version"""
387
+ return self.__get(self.url + "/rest/data/FirmwareVersion")["data"]
388
+
389
+ def getImageVersion(self):
390
+ """Returns the image version of the Vireo"""
391
+ return self.__get(self.url + "/rest/data/ImageVersion")["data"]
392
+
393
+ def zeroChannelHistogram(self, channel: int):
394
+ self.__post(self.url + f"/rest/config/{channel}/ZeroHistogramCounts")
395
+
396
+ def configureCoincidence(
397
+ self,
398
+ mode: Literal["hit_pattern", "multiplicity"],
399
+ # Note, this will probably require some more complicated typing
400
+ multiplicity: Optional[int] = None,
401
+ hit_pattern: Optional[Dict[str, str]] = None,
402
+ ):
403
+ """
404
+ Configures coincidence prior to an experimental run.
405
+
406
+ :param mode: the coincidence mode for triggering. Must be one of two options.
407
+ - "multiplicity" : global trigger requires at least the
408
+ specified number of individual channels to trigger
409
+ - "hit_pattern" : global trigger requires specific channels to trigger or not trigger.
410
+ AKA coincidence/anti-coincidence/ignore hit pattern
411
+
412
+ :param multiplicity: Required if mode = "multiplicity". The minimum number
413
+ individual channel triggers required to define an Event.
414
+ This arugment is ignored if mode is "hit_pattern"
415
+
416
+ :param hit_pattern: Required if mode = "hit_pattern". This argument must be a dictionary. Keys are the channel number,
417
+ and value is one of:
418
+
419
+ * 'COINCIDENCE' : If a trigger is required on this channel
420
+ * 'ANTICOINCIDENCE' : If a trigger is not allowed on this channel
421
+ * 'IGNORE' : If triggers on this channel have no impact on Event
422
+
423
+ All channels must be present when presented to configureCoincidence. A simple builder
424
+ for configureCoincidence is helpers.HitPatternCoincidenceBuilder which will fill in unspecified items with "IGNORE" exists.
425
+ This arugment is ignored if mode is "multiplicity"
426
+
427
+ Hit Pattern Example
428
+ -------------------
429
+
430
+ .. code-block:: python
431
+ :linenos:
432
+
433
+ hit_pattern = {"channel_0_trigger_hit_pattern" : "COINCIDENCE", "channel_1_trigger_hit_pattern" : "ANTICOINCIDENCE"}
434
+ digitizer.configureCoincidence("hit_pattern", hit_pattern=hit_pattern)
435
+
436
+
437
+ Multiplicity Example
438
+ --------------------
439
+
440
+ .. code-block:: python
441
+ :linenos:
442
+
443
+ multiplicity = 3
444
+ digitizer.configureCoincidence("multiplicity", multiplicity=multiplicity)
445
+
446
+ """
447
+ t: Dict[str, Any] = {
448
+ "coincidence_mode": mode,
449
+ }
450
+ if mode not in ["multiplicity", "hit_pattern"]:
451
+ raise ValueError("Invalid mode pattern!")
452
+ if mode == "multiplicity":
453
+ if multiplicity is None:
454
+ raise ValueError(
455
+ "If mode is multiplicity, the multiplicity must be defined!"
456
+ )
457
+ t["trigger_multiplicity"] = multiplicity
458
+
459
+ if mode == "hit_pattern":
460
+ if hit_pattern is None:
461
+ raise ValueError(
462
+ "If mode is hit_pattern, the hit pattern must be defined!"
463
+ )
464
+
465
+ for channel in self.channels:
466
+ if f"channel_{channel}_trigger_hit_pattern" not in hit_pattern:
467
+ raise ValueError("A channel has been unspecified for behavior!")
468
+ for item in hit_pattern:
469
+ t[item] = hit_pattern[item]
470
+
471
+ self.__post(self.__config_url("global", "Coincidence"), t, print_failure=False)
472
+
473
+ def getCoincidenceSettings(self):
474
+ """
475
+ Obtain the current Coincidence settings.
476
+
477
+ Look at the FemtoDAQ web docs for more information on that packet, this function returns the "data" field of that packet
478
+ """
479
+ return self.__get(self.__config_url("global", "Coincidence"))["data"]
480
+
481
+ def configureRecording(
482
+ self,
483
+ channels: Sequence[int],
484
+ run_name: str = "API_Recording",
485
+ format_type: str = "gretina",
486
+ record_waves: bool = True,
487
+ record_summaries: bool = False,
488
+ directory: Optional[str] = None,
489
+ seq_file_size_MB: int = 100,
490
+ only_record_triggered_channels: bool = False,
491
+ ):
492
+ """
493
+ Configures file recording prior to an experimental run.
494
+
495
+ :param channels: list of channels to record during this experimental run
496
+ :param run_name: The name of this experimental run. This string will be
497
+ pre-pended along with a date code to all to the names of all data files
498
+ generated during this run
499
+ :param format_type: The file format to use. Call `getRecordingFormatInfo`
500
+ for a full list of data formats and the data products they support
501
+ :param record_waves: True to save waveforms. This will raise an
502
+ error if the specified file format doesn't support waveform recording.
503
+ Default is True.
504
+ :param record_summaries: True to save pulse summaries. This will
505
+ raise an error if the specified file format doesn't support pulse
506
+ summary recording. Default is False.
507
+ :param directory: The remote directory on your FemtoDAQ unit to save data
508
+ to. If left as None, then defaults to the data partition directory
509
+ on the FemtoDAQ unit. SkuTek recommends keeping this as it's default
510
+ unless you want the FemtoDAQ unit to save data over an NFS mount.
511
+ :param seq_file_size_MB: Maximum size data file in MB before a new file
512
+ is created. 100MB by default.
513
+ :param only_record_triggered_channels: If True, then only record the channels
514
+ in the `channels` list that triggered in the event. This is more efficient
515
+ and reduces the liklihood of noise waveforms ending up in your data files.
516
+ If left as False, the default, then all specified channels will be written
517
+ to disk even if no trigger was detected. This is less efficient, but ensures
518
+ that the same channels will be in each event.
519
+
520
+ *warning* Setting this to True can result in varying event sizes and in
521
+ some cases can result in empty cells in row/column type file formats
522
+ such the IGOR Pulse Height format.
523
+
524
+
525
+ :raise RuntimeError: if record_waves is True, but the specified file format
526
+ does not support waveform recording.
527
+ :raise RuntimeError: if record_summaries is True, but the specified file format
528
+ does not support pulse summary recording.
529
+ :raise RuntimeError: if directory is specified, but does not exist or is inaccessible
530
+ from your FemtoDAQ digitizer.
531
+ """
532
+ t: Dict[str, Any] = {
533
+ "channels_to_record": channels,
534
+ "run_name": run_name,
535
+ "format_type": format_type.lower(),
536
+ "record_waves": record_waves,
537
+ "record_summaries": record_summaries,
538
+ "directory": directory,
539
+ "seq_file_size_MB": seq_file_size_MB,
540
+ "only_record_triggered": only_record_triggered_channels,
541
+ }
542
+ self.__post(self.__config_url("global", "RecordingSettings"), t)
543
+
544
+ def getRecordingSettings(self):
545
+ """
546
+ Get the current recording settings, for more information, look at the FemtoDAQ WebAPI docs for what the exact return is.
547
+ The function specifically returns the "data" portion of the packet.
548
+ """
549
+ return self.__get(self.__config_url("global", "RecordingSettings"))["data"]
550
+
551
+ def configureSoftwareStreaming(
552
+ self,
553
+ channels: Sequence[int],
554
+ format: str,
555
+ target_ip: str,
556
+ target_port: Union[int, str],
557
+ only_stream_triggered_channels: bool = False,
558
+ ):
559
+ """
560
+ Configures streaming readout from software prior to an experimental run.
561
+
562
+ :param channels: list of channels to stream during this experimental run
563
+ :param target_ip: The IP address to stream to.
564
+ :param target_port: The network port at the specified IP address to stream to.
565
+ :param only_stream_triggered_channels: If True, then only record the channels
566
+ in the `channels` list that triggered in the event. This is more efficient
567
+ and reduces the liklihood of noise waveforms ending up in your data files.
568
+ If left as False, the default, then all specified channels will be written
569
+ to disk even if no trigger was detected. This is less efficient, but ensures
570
+ that the same channels will be in each event.
571
+
572
+ """
573
+
574
+ ENDPOINT = self.__config_url("global", "SoftwareStreamSettings")
575
+ self.__post(
576
+ ENDPOINT,
577
+ {
578
+ "soft_stream_channels": channels,
579
+ "soft_stream_dest_ip": target_ip,
580
+ "soft_stream_dest_port": target_port,
581
+ "soft_stream_format": format,
582
+ "only_stream_triggered": only_stream_triggered_channels,
583
+ },
584
+ )
585
+
586
+ def getSoftwareStreamSettings(self):
587
+ """
588
+ Retrieve the stream settings currently made for the Vireo.
589
+ :returns: Dict of a json packet
590
+ The JSON packet should look like this:
591
+
592
+ .. code-block::
593
+
594
+ {
595
+ "soft_stream_channels": channels,
596
+ "soft_stream_dest_ip": target_ip,
597
+ "soft_stream_dest_port": int | str,
598
+ "only_stream_triggered": bool
599
+ }
600
+
601
+ """
602
+ return self.__get(self.__config_url("global", "SoftwareStreamSettings"))["data"]
603
+
604
+ def getRunStatistics(self) -> Dict[str, Any]:
605
+ """returns a dictionary which contains at least the following keys
606
+
607
+ 'run_time' : duration of run
608
+ 'number_of_packets_streamed_from_software' : number of packets that have been streamed from
609
+ from our software streaming system
610
+ 'number_of_events_recorded' : number of events saved to disk via the Recording System
611
+
612
+ """
613
+ raise NotImplementedError()
614
+
615
+ # #########################################################################
616
+ # Per-Channel Settings
617
+ # #########################################################################
618
+
619
+ # _________________________________________________________________________
620
+ # AnalogOffsetPercent
621
+ # def getAnalogOffsetPercent(self, channel):
622
+ # route = self.__config_url(channel, "AnalogOffsetPercent")
623
+ # resp = self.__get(route)
624
+ # return resp['data']
625
+
626
+ def setAnalogOffsetPercent(self, channel: int, offset_percent: int):
627
+ """
628
+ Set the analog offset as a percentage for a given channel.
629
+ This value is unable to be read back.
630
+
631
+ :param channel: Channel to set the offset
632
+ :param offset_percent: The percent offset for analog baseline offset ranging from -100 to 100 as an integer
633
+ :raise ValueError: If the offset percentage is not in the valid range
634
+ """
635
+ if offset_percent not in range(-100, 100):
636
+ raise ValueError("Offset percent not in valid range!")
637
+ route = self.__config_url(channel, "AnalogOffsetPercent")
638
+ data = {"offset_percent": offset_percent}
639
+ self.__post(route, data)
640
+
641
+ # _________________________________________________________________________
642
+ # DigitalOffset
643
+ def getDigitalOffset(self, channel: int):
644
+ """
645
+ Get the digital offset for a specified channel in ADC counts difference from the value to be displayed.
646
+ This means that the offset is **not** inverted when you enable inverting waveforms.
647
+
648
+ :param channel: channel to get the offset from
649
+ """
650
+ route = self.__config_url(channel, "DigitalOffset")
651
+ resp = self.__get(route)
652
+ return resp["data"]
653
+
654
+ def setDigitalOffset(self, channel: int, offset: int):
655
+ """
656
+ Set the digital offset of a specified channel in ADC counts difference from the value to be displayed.
657
+ This means that the offset is **not** inverted when you enable inverting waveforms.
658
+
659
+ :param channel: channel to get the offset from
660
+ :param offset: Offset in ADC counts
661
+ """
662
+ route = self.__config_url(channel, "DigitalOffset")
663
+ data = {"offset": offset}
664
+ self.__post(route, data)
665
+
666
+ # _________________________________________________________________________
667
+ # EnableTrigger
668
+ def getEnableTrigger(self, channel: int):
669
+ """
670
+ Get whether a trigger is specified for a channel
671
+
672
+ :param channel: Channel to get the trigger enabled status from.
673
+ """
674
+ route = self.__config_url(channel, "EnableTrigger")
675
+ resp = self.__get(route)
676
+ return resp["data"]
677
+
678
+ def setEnableTrigger(self, channel: int, enable: bool):
679
+ """
680
+ Set the status of a trigger for a specified channel.
681
+
682
+ :param channel: Channel to enable or disable the triggering on.
683
+ :param enable: Enable or disable triggering on this channel
684
+ """
685
+ route = self.__config_url(channel, "EnableTrigger")
686
+ data = {"enable": enable}
687
+ self.__post(route, data)
688
+
689
+ # _________________________________________________________________________
690
+ # TriggerEdge
691
+ def getTriggerEdge(self, channel: int):
692
+ """
693
+ Get what edge a trigger happens for a specified channel.
694
+ :param channel: channel to get the trigger edge data from.
695
+ """
696
+ route = self.__config_url(channel, "TriggerEdge")
697
+ resp = self.__get(route)
698
+ return resp["data"]
699
+
700
+ def setTriggerEdge(
701
+ self,
702
+ channel: int,
703
+ direction: Union[Union[Literal["rising"], Literal["falling"]], int],
704
+ ):
705
+ """
706
+ Set whether the trigger is to be on the rising or falling edge of a waveform.
707
+ This applies *AFTER* inversion.
708
+
709
+ :param channel: Channel to set the trigger edge detection on.
710
+ :param direction: Direction of travel, rising or falling edge.
711
+ """
712
+ route = self.__config_url(channel, "TriggerEdge")
713
+ data = {"direction": direction}
714
+ self.__post(route, data)
715
+
716
+ # _________________________________________________________________________
717
+ # TriggerSensitivity
718
+ def getTriggerSensitivity(self, channel: int):
719
+ """
720
+ Get the trigger threshold of the specified channel.
721
+
722
+ :param channel: channel to obtain the trigger threshold of.
723
+ """
724
+ route = self.__config_url(channel, "TriggerSensitivity")
725
+ resp = self.__get(route)
726
+ return resp["data"]
727
+
728
+ def setTriggerSensitivity(self, channel: int, sensitivity: int):
729
+ """
730
+ Set the trigger threshold of the specified channel
731
+
732
+ :param channel: channel to set the trigger threshold of.
733
+ :param sensitivity: Threshold of the trigger in ADC counts.
734
+ """
735
+ route = self.__config_url(channel, "TriggerSensitivity")
736
+ data = {"sensitivity": sensitivity}
737
+ self.__post(route, data)
738
+
739
+ # _________________________________________________________________________
740
+ # TriggerAveraging
741
+ def getTriggerAveragingWindow(self, channel: int):
742
+ """
743
+ Get the duration of the leading and trailing summation windows in ADC samples.
744
+ :param channel: channel to get the trigger averaging window of.
745
+ """
746
+ route = self.__config_url(channel, "TriggerAveragingWindow")
747
+ resp = self.__get(route)
748
+ return resp["data"]
749
+
750
+ def setTriggerAveragingWindow(self, channel: int, window_width: int):
751
+ """
752
+ Set the trigger averaging window, the valid range is determined by the device, but a typical valid range would be:
753
+ [1, 2, 4, 8, 16, 32] in terms of ADC samples to average for triggering.
754
+
755
+ :param channel: channel to set the trigger averaging window
756
+ :param window_width: width of the trigger averaging window
757
+ """
758
+ route = self.__config_url(channel, "TriggerAveragingWindow")
759
+ data = {"window_width": window_width}
760
+ self.__post(route, data)
761
+
762
+ # _________________________________________________________________________
763
+ # HistogramScaling
764
+ def getHistogramScaling(self, channel: int):
765
+ """
766
+ Get the state of the histogram scaling for a specified channel
767
+ :param channel: Channel to get the histogram scaling state from.
768
+ """
769
+ route = self.__config_url(channel, "HistogramScaling")
770
+ resp = self.__get(route)
771
+ return resp["data"]
772
+
773
+ def setHistogramScaling(self, channel: int, state: int):
774
+ """
775
+ Set the histogram scaling for a specified channel, if state is 1, this bins by 2, otherwise for 0, do not bin.
776
+ To cover the whole ADC range state must be one
777
+
778
+ :param channel: Channel to set the histogram scaling state
779
+ :param state: State to set the histogram scaling in, typically 1 for bin by 2 or 0 for no binning.
780
+ """
781
+ route = self.__config_url(channel, "HistogramScaling")
782
+ data = {"scale_factor": state}
783
+ self.__post(route, data)
784
+
785
+ # _________________________________________________________________________
786
+ # HistogramQuantity
787
+ def getHistogramQuantity(self, channel: int):
788
+ """
789
+ Get the quantity histogrammed at each event, check setHistogramQuantity for the meanings of values
790
+
791
+ :param channel: Channel to get the quantity histogrammed per event
792
+ """
793
+ route = self.__config_url(channel, "HistogramQuantity")
794
+ resp = self.__get(route)
795
+ return resp["data"]
796
+
797
+ def setHistogramQuantity(self, channel: int, quantity: int):
798
+ """
799
+ Set the quantity histogrammed at each event.
800
+
801
+ 0 is the maximum value of a trace after averaging
802
+
803
+ 1 is the running sum over the PulseHeight window without averaging
804
+
805
+ 2 for running average of the PulseHeight window sum (AKA the average of mode 1)
806
+
807
+ 3 for the maximum value of the trigger waveform after averaging.
808
+
809
+ :param channel: channel to set what quantity is being histogrammed
810
+ :param quantity: What quantity do we want to histogram on this channel.
811
+ """
812
+ route = self.__config_url(channel, "HistogramQuantity")
813
+ data = {"quantity": quantity}
814
+ self.__post(route, data)
815
+
816
+ # #########################################################################
817
+ # Runtime Functions not present in GUI
818
+ # #########################################################################
819
+ # ClearTimestamp
820
+ def clearTimestamp(self):
821
+ """
822
+ Clear the device's FPGA timestamp
823
+ """
824
+ route = self.__runtime_url("ClearTimestamp")
825
+ data = {"clear_timestamp": True}
826
+ self.__post(route, data)
827
+
828
+ # GetTimestamp
829
+ def getTimestamp(self):
830
+ """
831
+ Get the device's FPGA timestamp
832
+ """
833
+ route = self.__runtime_url("GetTimestamp")
834
+ self.__get(route)
835
+
836
+ # #########################################################################
837
+ # Functions that call routes already present in GUI
838
+ # #########################################################################
839
+ # _________________________________________________________________________
840
+ def Start(self) -> None:
841
+ raise NotImplementedError("This feature is not yet supported")
842
+ # # WARNING: NO SPY Support at this time
843
+
844
+ # gui_setup_args = {}
845
+ # gui_setup_args['trigger_mode'] = trigger_mode
846
+ # gui_setup_args['trigger_multiplicity'] = trigger_multiplicity
847
+ # gui_setup_args['channels'] = {}
848
+ # for channel in range(self.num_channels):
849
+ # gui_setup_args['channels'][channel] = {}
850
+ # if channel in coincidence_channels:
851
+ # gui_setup_args['channels'][channel]['trigger_hit_pattern'] = "COINCIDENCE"
852
+ # elif channel in anticoincidence_channels:
853
+ # gui_setup_args['channels'][channel]['trigger_hit_pattern'] = "ANTICOINCIDENCE"
854
+ # else:
855
+ # gui_setup_args['channels'][channel]['trigger_hit_pattern'] = "IGNORE"
856
+
857
+ # _________________________________________________________________________
858
+ def Stop(self) -> None:
859
+ raise NotImplementedError("This feature is not yet supported")
860
+
861
+ # *************************************************************************
862
+ # Recording Control
863
+ # _________________________________________________________________________
864
+ def getListOfDataFiles(self, last_run_only: bool = False) -> Sequence[str]:
865
+ """
866
+ Get the list of all remote data files.
867
+
868
+ :param last_run_only: If true, only gets the data files recorded in the last run.
869
+ """
870
+ json_data = {"file_extension": "ALL"}
871
+ route = self.__gui_url("GET_LIST_OF_FILES")
872
+ resp = self.__post(route, json_data)
873
+ files: List[str] = []
874
+ for filedata in resp["data"]:
875
+ if last_run_only:
876
+ if filedata.get("collected_during_last_run", False):
877
+ files.append(filedata["filename"])
878
+ else:
879
+ files.append(filedata["filename"])
880
+ return files
881
+
882
+ # _________________________________________________________________________
883
+ def downloadFile(
884
+ self, filename: str, save_to: Optional[str] = None, silent: bool = False
885
+ ):
886
+ """
887
+ Download a file from a given path, save to a location on disk, and optionally print out values
888
+
889
+ :param filename: Remote file to download
890
+ :param save_to: location to save that file to, or the local destionation
891
+ :param silent: Don't print values out
892
+ """
893
+ # default to the current working directory
894
+ save_to = os.getcwd() if (save_to is None) else save_to
895
+ # make sure we have write permissions to the directory
896
+ assert os.access(save_to, os.W_OK), (
897
+ f"Unable to write files to directory '{save_to}'"
898
+ )
899
+ # Download the file
900
+ download_url = self.__download_url(filename)
901
+ try:
902
+ dest_path = os.path.join(save_to, filename)
903
+ urllib.request.urlretrieve(download_url, dest_path)
904
+ except Exception:
905
+ if not silent:
906
+ print(
907
+ f"unable to download data file '{filename}' at url '{download_url}'"
908
+ )
909
+ raise
910
+
911
+ if not silent:
912
+ print(f"{str(self)} Controller : downloaded `{filename}` to '{dest_path}'")
913
+
914
+ # _________________________________________________________________________
915
+ def downloadLastRunDataFiles(self, save_to: Optional[str] = None):
916
+ # iterate through all run files and download them one by one
917
+ """
918
+ Iterate through all data files from the last run and download them.
919
+
920
+ :param save_to: an optional parameter specifying where to save the data.
921
+ """
922
+ for filename in self.getListOfDataFiles(True):
923
+ self.downloadFile(filename, save_to)
924
+
925
+ def isReserved(self) -> bool:
926
+ """
927
+ Determine if the FemtoDAQ is reserved
928
+ """
929
+ return self.__post(
930
+ self.__gui_url("LOAD_JSON_FROM_FILE"),
931
+ {"filepath": "/var/www/data/config/reserve_info.json"},
932
+ )["data"]["reserved"]
933
+
934
+ def getReservedInfo(self):
935
+ """
936
+ Get the reservation information of a FemtoDAQ device
937
+ """
938
+ return self.__post(
939
+ self.__gui_url("LOAD_JSON_FROM_FILE"),
940
+ {"filepath": "/var/www/data/config/reserve_info.json"},
941
+ )["data"]
942
+
943
+ def setReservedInfo(
944
+ self,
945
+ reserved_status: bool,
946
+ reserver_name: str,
947
+ reserve_contact: str,
948
+ reserve_message: str,
949
+ ):
950
+ """
951
+ Set the reservation status of a FemtoDAQ device.
952
+ Note: This is not strictly enforced
953
+ """
954
+ self.__post(
955
+ self.__gui_url("SAVE_JSON_TO_FILE"),
956
+ {
957
+ "data": {
958
+ "reserved": reserved_status,
959
+ "reserve_name": reserver_name,
960
+ "reserve_contact": reserve_contact,
961
+ "reserve_message": reserve_message,
962
+ },
963
+ "filepath": "/var/www/data/config/reserve_info.json",
964
+ },
965
+ )
966
+
967
+ # def start_waveform_capture():
968
+ # route = self.__gui_url('START_WAVEFORM_CAPTURE')
969
+ # payload = {'data': {}}
970
+
971
+ # def stop_waveform_capture():
972
+
973
+ # def force_trigger():
974
+
975
+ # def start_histogram_capture():
976
+
977
+ # def stop_histogram_capture():
978
+
979
+ # def zero_histograms():
980
+
981
+ # #########################################################################
982
+ # General User Properties and Magic
983
+ # #########################################################################
984
+
985
+ # _________________________________________________________________________
986
+ @property
987
+ def num_channels(self):
988
+ """The number of channels in the product"""
989
+ return self.fpga_data["num_channels"]
990
+
991
+ # _________________________________________________________________________
992
+ @property
993
+ def channels(self):
994
+ """A list of all channels in the product"""
995
+ return list(range(0, self.num_channels))
996
+
997
+ # _________________________________________________________________________
998
+ @property
999
+ def num_wave_samples(self):
1000
+ """Number of samples in a max-size waveform"""
1001
+ return self.fpga_data["num_wave_samples"]
1002
+
1003
+ # _________________________________________________________________________
1004
+ @property
1005
+ def wave_max_val(self):
1006
+ """Maximum value in the wave"""
1007
+ return self.fpga_data["constraints"]["wave_max_val"]
1008
+
1009
+ # _________________________________________________________________________
1010
+ @property
1011
+ def wave_min_val(self):
1012
+ """Minimum value in the wave"""
1013
+ return self.fpga_data["constraints"]["wave_min_val"]
1014
+
1015
+ # _________________________________________________________________________
1016
+ @property
1017
+ def adc_max_val(self):
1018
+ """Maximum ADC value of the product"""
1019
+ return self.fpga_data["constraints"]["adc_max_val"]
1020
+
1021
+ # _________________________________________________________________________
1022
+ @property
1023
+ def adc_min_val(self):
1024
+ """Minimum ADC value of the product"""
1025
+ return self.fpga_data["constraints"]["adc_min_val"]
1026
+
1027
+ # _________________________________________________________________________
1028
+ @property
1029
+ def trigger_sensitivity_max(self):
1030
+ """Maximum trigger sensitivity"""
1031
+ return self.adc_max_val
1032
+
1033
+ # _________________________________________________________________________
1034
+ @property
1035
+ def trigger_sensitivity_min(self):
1036
+ """Minimum trigger sensitivity"""
1037
+ return self.adc_min_val
1038
+
1039
+ # _________________________________________________________________________
1040
+ @property
1041
+ def product_name(self):
1042
+ """Get the short name of the FemtoDAQ product"""
1043
+ return self.fpga_data["product_short"]
1044
+
1045
+ # _________________________________________________________________________
1046
+ @property
1047
+ def serial_number(self):
1048
+ """Get the serial name of the product"""
1049
+ return self.fpga_data["serial_num_str"]
1050
+
1051
+ # _________________________________________________________________________
1052
+ @property
1053
+ def name(self):
1054
+ """Get the whole name of the product, being product-serial number"""
1055
+ return f"{self.product_name}-{self.serial_number}"
1056
+
1057
+ # _________________________________________________________________________
1058
+ def __str__(self):
1059
+ return f"{self.name} ({self.url})"
1060
+
1061
+ # _________________________________________________________________________
1062
+ def __repr__(self):
1063
+ return str(self)
1064
+
1065
+
1066
+ # Image Version
1067
+ # Software Version
1068
+ # Firmware Version