wasatch 2.3.2__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.
Files changed (53) hide show
  1. wasatch/AbstractUSBDevice.py +29 -0
  2. wasatch/AndorDevice.py +910 -0
  3. wasatch/AreaScanImage.py +19 -0
  4. wasatch/AutoRaman.py +457 -0
  5. wasatch/AutoRamanRequest.py +96 -0
  6. wasatch/BLEDevice.py +1032 -0
  7. wasatch/BalanceAcquisition.py +136 -0
  8. wasatch/CSVLoader.py +188 -0
  9. wasatch/CommandSettings.py +95 -0
  10. wasatch/ControlObject.py +17 -0
  11. wasatch/DetectorROI.py +36 -0
  12. wasatch/DetectorRegions.py +158 -0
  13. wasatch/DeviceFinderBLE.py +107 -0
  14. wasatch/DeviceFinderTCP.py +20 -0
  15. wasatch/DeviceFinderUSB.py +236 -0
  16. wasatch/DeviceID.py +344 -0
  17. wasatch/EEPROM.py +1450 -0
  18. wasatch/EEPROM_multical.sav +48 -0
  19. wasatch/FPGAOptions.py +125 -0
  20. wasatch/FeatureIdentificationDevice.py +3961 -0
  21. wasatch/FirmwareRequirements.py +76 -0
  22. wasatch/HardwareInfo.py +36 -0
  23. wasatch/IDSCamera.py +916 -0
  24. wasatch/IDSDevice.py +329 -0
  25. wasatch/IMX385.py +179 -0
  26. wasatch/InterfaceDevice.py +40 -0
  27. wasatch/MockUSBDevice.py +353 -0
  28. wasatch/OceanDevice.py +215 -0
  29. wasatch/PollStatus.py +15 -0
  30. wasatch/ProcessedReading.py +352 -0
  31. wasatch/ROI.py +33 -0
  32. wasatch/Reading.py +80 -0
  33. wasatch/RealUSBDevice.py +64 -0
  34. wasatch/SPIDevice.py +628 -0
  35. wasatch/SpectrometerRequest.py +15 -0
  36. wasatch/SpectrometerResponse.py +38 -0
  37. wasatch/SpectrometerSettings.py +446 -0
  38. wasatch/SpectrometerState.py +255 -0
  39. wasatch/StatusMessage.py +14 -0
  40. wasatch/TCPDevice.py +409 -0
  41. wasatch/TakeOneRequest.py +43 -0
  42. wasatch/WasatchBus.py +87 -0
  43. wasatch/WasatchDevice.py +1060 -0
  44. wasatch/WasatchDeviceWrapper.py +452 -0
  45. wasatch/WrapperWorker.py +304 -0
  46. wasatch/__init__.py +4 -0
  47. wasatch/applog.py +177 -0
  48. wasatch/simulation_protocol.py +227 -0
  49. wasatch/utils.py +731 -0
  50. wasatch-2.3.2.dist-info/LICENSE +21 -0
  51. wasatch-2.3.2.dist-info/METADATA +347 -0
  52. wasatch-2.3.2.dist-info/RECORD +53 -0
  53. wasatch-2.3.2.dist-info/WHEEL +4 -0
@@ -0,0 +1,1060 @@
1
+ import re
2
+ import os
3
+ import time
4
+ import numpy as np
5
+ import psutil
6
+ import logging
7
+ import datetime
8
+ import threading
9
+ from queue import Queue
10
+ from typing import Any
11
+
12
+ from .FeatureIdentificationDevice import FeatureIdentificationDevice
13
+ from .SpectrometerSettings import SpectrometerSettings
14
+ from .SpectrometerResponse import SpectrometerResponse
15
+ from .SpectrometerRequest import SpectrometerRequest
16
+ from .SpectrometerResponse import ErrorLevel
17
+ from .InterfaceDevice import InterfaceDevice
18
+ from .BalanceAcquisition import BalanceAcquisition
19
+ from .SpectrometerState import SpectrometerState
20
+ from .ControlObject import ControlObject
21
+ from .AutoRaman import AutoRaman
22
+ from .DeviceID import DeviceID
23
+ from .Reading import Reading
24
+
25
+ log = logging.getLogger(__name__)
26
+
27
+ class WasatchDevice(InterfaceDevice):
28
+ """
29
+ This is the top-level interface for controlling and communicating with
30
+ Wasatch Photonics USB 2.0 spectrometers using the FeatureInterfaceDevice
31
+ (FID) protocol as defined in ENG-0001.
32
+
33
+ This class is essentially a clunky wrapper over FeatureInterfaceDevice,
34
+ providing the higher-level InterfaceDevice interface making WasatchDevice a
35
+ peer to AndorDevice, OceanDevice, TCPDevice, IDSDevice etc.
36
+
37
+ Several points will stand out:
38
+
39
+ 1. WasatchDevice should probably be renamed to FIDDevice, because Wasatch
40
+ Photonics is also the manufacturer for spectrometers using AndorDevice,
41
+ TCPDevice, IDSDevice etc.
42
+
43
+ 2. This class could be simplified considerably, and possibly merged with
44
+ FeatureIdentificationDevice.
45
+
46
+ ENLIGHTEN does not instantiate WasatchDevices directly, but instead uses
47
+ a WasatchDeviceWrapper to access a single WasatchDevice in a dedicated child
48
+ thread. Other users of Wasatch.PY may of course instantiate a WasatchDevice
49
+ directly, or go straight to a FeatureInterfaceDevice.
50
+
51
+ Main things this "wrapper" provides:
52
+
53
+ - Implements software-based, library-based scan averaging (badly). Although
54
+ ARM-based spectrometers can do this in firmware, FX2-based models have to
55
+ do it in software. Between Wasatch.PY and ENLIGHTEN, it seems preferable
56
+ to have this in the Wasatch.PY library, where it is easier for customers
57
+ to use.
58
+
59
+ - Distinguishes between normal, averaged, AutoRaman and AreaScan
60
+ acquisitions.
61
+
62
+ @par History
63
+
64
+ This class exists because it used to wrap TWO types of spectrometers:
65
+
66
+ - FeatureIdentificationDevices, which follow the ENG-0001 API and
67
+ have EEPROMs
68
+ - StrokerDevices, which didn't have EEPROMs and didn't obey ENG-0001.
69
+
70
+ I don't know if we have an ENG document specifying the protocol that the old
71
+ Stroker electronics used. It was already deprecated when I started, and was
72
+ rapidly removed from Wasatch.PY. Which kind of made this class superfluous.
73
+
74
+ This class (and all who inherit InterfaceDevice) use a somewhat clunky
75
+ data-passing and error-handling mechanism via SpectrometerRequest and
76
+ SpectrometerResponse objects. That could probably be streamlined.
77
+
78
+ Part of the legacy "ugly-isms" in this class date back to when ENLIGHTEN
79
+ was multi-process rather than multi-threaded, and all data flows between
80
+ Wasatch.PY and ENLIGHTEN were via pickled queues :-(
81
+ """
82
+
83
+ def __init__(self, device_id, message_queue=None, alert_queue=None):
84
+ """
85
+ @param device_id a DeviceID instance OR string label thereof
86
+ @param message_queue if provided, used to send status back to caller
87
+ @param alert_queue if provided, used to receive hints and realtime interrupts from caller
88
+ """
89
+ # if passed a string representation of a DeviceID, deserialize it
90
+ if type(device_id) is str:
91
+ device_id = DeviceID(label=device_id)
92
+
93
+ self.device_id = device_id
94
+ self.message_queue = message_queue # outgoing notifications to ENLIGHTEN
95
+ self.alert_queue = alert_queue # incoming alerts from ENLIGHTEN
96
+
97
+ self.lock = threading.Lock()
98
+
99
+ self.connected = False
100
+ self.hardware = None # FeatureIdentificationDevice
101
+
102
+ # Receives ENLIGHTEN's 'change settings' commands in the spectrometer
103
+ # process. Although a logical queue, has nothing to do with multiprocessing.
104
+ self.command_queue = []
105
+
106
+ # Enable for "immediate mode" by clients like WasatchShell (by default,
107
+ # inbound commands are queued and executed at beginning of next acquire_data;
108
+ # this runs them as they arrive).
109
+ self.immediate_mode = False
110
+
111
+ self.settings = SpectrometerSettings()
112
+
113
+ # Any particular reason these aren't in FeatureIdentificationDevice?
114
+ self.summed_spectra = None
115
+ self.sum_count = 0
116
+ self.session_reading_count = 0
117
+ self.take_one_request = None
118
+ self.last_complete_acquisition = None
119
+
120
+ self.process_id = os.getpid()
121
+ self.last_memory_check = datetime.datetime.now()
122
+ self.last_battery_percentage = 0
123
+
124
+ self.process_f = self._init_process_funcs()
125
+
126
+ self.auto_raman = AutoRaman(self)
127
+
128
+ # ######################################################################## #
129
+ # #
130
+ # Connection #
131
+ # #
132
+ # ######################################################################## #
133
+
134
+ def connect(self):
135
+ """ Attempt low level connection to the specified DeviceID """
136
+ if self.device_id.is_usb() or self.device_id.is_mock():
137
+ log.debug(f"trying to connect to {'USB' if self.device_id.is_usb() else 'Mock'}")
138
+ result = self.connect_feature_identification()
139
+ if result.data:
140
+ log.debug("Connected to FeatureIdentificationDevice")
141
+ self.connected = True
142
+ self.initialize_settings()
143
+ return SpectrometerResponse(True)
144
+ else:
145
+ log.debug("Failed to connect to FeatureIdentificationDevice")
146
+ return result
147
+ else:
148
+ log.critical("unsupported DeviceID protocol: %s", self.device_id)
149
+ log.debug("Can't connect to %s", self.device_id)
150
+ return SpectrometerResponse(False)
151
+
152
+ def disconnect(self):
153
+ log.debug("WasatchDevice.disconnect: calling hardware disconnect")
154
+ try:
155
+ req = SpectrometerRequest("disconnect")
156
+ self.hardware.handle_requests([req])
157
+ except:
158
+ log.critical("Issue disconnecting hardware", exc_info=1)
159
+
160
+ time.sleep(0.1)
161
+
162
+ self.connected = False
163
+ return True
164
+
165
+ ## Given a specified universal identifier, attempt to connect to the device using FID protocol.
166
+ # @todo merge with the hardcoded list in DeviceFinderUSB
167
+ def connect_feature_identification(self):
168
+ # check to see if valid FID PID
169
+ pid_hex = self.device_id.get_pid_hex()
170
+ if pid_hex not in ["1000", "2000", "4000"]:
171
+ log.debug(f"connect_feature_identification: device_id {self.device_id} has invalid PID {pid_hex}")
172
+ return SpectrometerResponse(False)
173
+
174
+ dev = None
175
+ try:
176
+ log.debug(f"connect_fid: instantiating FID with device_id {self.device_id}, pid {pid_hex}")
177
+ dev = FeatureIdentificationDevice(device_id=self.device_id, message_queue=self.message_queue, alert_queue=self.alert_queue)
178
+ log.debug("connect_fid: instantiated")
179
+
180
+ log.debug("connect_fid: calling dev.connect")
181
+ response = dev.connect()
182
+ log.debug("connect_fid: back from dev.connect")
183
+
184
+ if not response.data:
185
+ log.critical("Low level failure in device connect")
186
+ return response
187
+
188
+ self.hardware = dev
189
+ except:
190
+ log.critical(f"Problem connecting to {self.device_id}", exc_info=1)
191
+ return SpectrometerResponse(False)
192
+
193
+ log.debug(f"Connected to FeatureIdentificationDevice {self.device_id}")
194
+ return SpectrometerResponse(True)
195
+
196
+ def initialize_settings(self):
197
+ if not self.connected:
198
+ return
199
+
200
+ # WasatchDevice and FID share same SpectrometerSettings
201
+ self.settings = self.hardware.settings
202
+
203
+ req_int = SpectrometerRequest('get_integration_time_ms')
204
+ req_gain = SpectrometerRequest('get_detector_gain')
205
+ reqs = [req_int, req_gain]
206
+ self.hardware.handle_requests(reqs)
207
+
208
+ # could read the defaults for these ss.state volatiles from FID too:
209
+ #
210
+ # self.tec_setpoint_degC
211
+ # self.high_gain_mode_enabled
212
+ # self.triggering_enabled
213
+ # self.laser_enabled
214
+ # self.laser_power
215
+ # self.ccd_offset
216
+
217
+ self.settings.update_wavecal()
218
+ self.settings.update_raman_intensity_factors()
219
+ self.settings.dump()
220
+
221
+ # ######################################################################## #
222
+ # #
223
+ # Acquisition #
224
+ # #
225
+ # ######################################################################## #
226
+
227
+ def acquire_data(self):
228
+ """
229
+ Process all enqueued settings, then read actual data (spectrum and
230
+ temperatures) from the device.
231
+
232
+ ENLIGHTEN calls this function via WrapperWorker.run().
233
+
234
+ @see Controller.acquire_reading
235
+ """
236
+ log.debug("acquire_data: start")
237
+
238
+ if self.hardware.shutdown_requested:
239
+ log.critical("acquire_data: hardware shutdown requested")
240
+ return SpectrometerResponse(False, poison_pill=True)
241
+
242
+ # process queued commands, and find out if we've been asked to read a
243
+ # spectrum
244
+ self.process_commands()
245
+
246
+ # if we don't yet have an integration time, nothing to do
247
+ if self.settings.state.integration_time_ms <= 0:
248
+ log.debug("skipping acquire_data because no integration_time_ms")
249
+ return SpectrometerResponse(None)
250
+
251
+ # if not self.hardware.is_sensor_stable():
252
+ # # technically, we could do all the other stuff in acquire_spectrum
253
+ # # (read battery, laser temperature, ambient temperature, detector
254
+ # # temperature etc), not bothering for now
255
+ # log.debug("declining to read spectra while stabilizing")
256
+ # return SpectrometerResponse(None)
257
+
258
+ # note that right now, all we return are Readings (encapsulating both
259
+ # spectra and temperatures). If we disable spectra, ENLIGHTEN stops
260
+ # receiving temperatures as well. In the future perhaps we should return
261
+ # multiple object types (Acquisitions, Temperatures, etc)
262
+
263
+ if self.settings.state.area_scan_enabled:
264
+ return self.acquire_area_scan()
265
+ else:
266
+ return self.acquire_spectrum()
267
+
268
+ ##
269
+ # Generate one Reading from the spectrometer, including one
270
+ # optionally-averaged spectrum, device temperatures and other hardware
271
+ # status.
272
+ #
273
+ # This is normally called by self.acquire_data when that function decides it
274
+ # is time to perform an acquisition.
275
+ #
276
+ # @par Scan Averaging
277
+ #
278
+ # IF the driver is in free-running mode, AND performing scan averaging,
279
+ # THEN scan averaging is NOT encapsulated within a single call to this
280
+ # function. Instead, we let ths spectrometer run in free-running mode,
281
+ # collecting individual spectra as per normal, and returning each "partial"
282
+ # readings while "building up" to the final averaged measurement.
283
+ #
284
+ # That is to say, if scan averaging is set to 10, then this function will
285
+ # get called 10 times, as ticked by the regular free-running timers, before
286
+ # the fully averaged spectrum is returned. A total of 10 (not 11) spectra
287
+ # will be generated and sent upstream: the first 9 "partial" (unaveraged)
288
+ # reads, and the final 10th spectrum which will contain the average of all
289
+ # 10 measurements.
290
+ #
291
+ # This gives the user-facing GUI an opportunity to update the "collected
292
+ # X-of-Y" readout on-screen, and potentially even graph the traces of
293
+ # in-process partial readings.
294
+ #
295
+ # HOWEVER, this doesn't make as much sense if we're not in free-running mode,
296
+ # i.e. the subprocess has been slaved to explicit control by the Controller
297
+ # (likely a feature object like BatchCollection), and is collecting exactly
298
+ # those measurements we're being commanded, as they're commanded.
299
+ #
300
+ # THEREFORE, if the driver IS NOT in free-running mode, then we ONLY return
301
+ # the final averaged spectrum as one atomic operation.
302
+ #
303
+ # In this case, if scan averaging is set to 10, then A SINGLE CALL to this
304
+ # function will "block" while the full 10 measurements are made, and then
305
+ # a single, fully-averaged spectrum will be returned upstream.
306
+ #
307
+ # @return a Reading wrapped in a SpectrometerResponse
308
+ #
309
+ def acquire_spectrum(self):
310
+ """
311
+ @returns a SpectrometerResponse(data=Reading)
312
+ """
313
+ if self.take_one_request and self.take_one_request.auto_raman_request:
314
+ return self.acquire_spectrum_auto_raman()
315
+ else:
316
+ return self.acquire_spectrum_standard()
317
+
318
+ def acquire_spectrum_auto_raman(self):
319
+ """
320
+ @returns a SpectrometerResponse(data=Reading)
321
+ @todo fold-in a lot of the post-reading sensor measurements
322
+ (temperature, interlock etc) provided by acquire_spectrum_standard
323
+ """
324
+ log.debug("WasatchDevice.acquire_spectrum_auto_raman: calling AutoRaman.measure")
325
+ spectrometer_response = self.auto_raman.measure(self.take_one_request.auto_raman_request)
326
+ reading = spectrometer_response.data
327
+ log.debug(f"WasatchDevice.acquire_spectrum_auto_raman: received {reading}")
328
+
329
+ # return the completed TakeOneRequest and clear our internal handle
330
+ #
331
+ # Note that Auto-Raman doesn't currently support "fast BatchCollection"
332
+ # with TakeOneRequest.readings_target. I think that's okay, because that's
333
+ # not really what Auto-Raman is for.
334
+ reading.take_one_request = self.take_one_request
335
+ self.take_one_request = None
336
+
337
+ return spectrometer_response
338
+
339
+ def acquire_spectrum_standard(self):
340
+ """
341
+ @returns a SpectrometerResponse(data=Reading)
342
+ """
343
+ acquire_response = SpectrometerResponse()
344
+
345
+ tor = self.take_one_request
346
+
347
+ self.perform_optional_throwaways()
348
+
349
+ ########################################################################
350
+ # Batch Collection silliness
351
+ ########################################################################
352
+
353
+ # Note that BatchCollection's "auto-enable laser" is not nearly as
354
+ # involved as the Auto-Raman performed by acquire_spectrum_auto_raman.
355
+ # BatchCollection uses TakeOneRequest.enable_laser_before,
356
+ # .disable_laser_after, .take_dark, .laser_warmup_ms and .scans_to_average
357
+ # to take an averaged, dark-corrected Raman measurement WITH FIXED
358
+ # ACQUISITION PARAMETERS (integration time, gain). It does not perform
359
+ # any optimization of integration time or gain.
360
+ #
361
+ # By implication then, we should simplify this by changing
362
+ # BatchCollection to use AutoRamanRequest, adding a checkbox to
363
+ # BatchCollection to allow # optimization; if unchecked, min/max integ
364
+ # and gain will equal start integ/gain, and no optimization will occur.
365
+ # Then we can remove all the auto laser stuff from the following code.
366
+ #
367
+ # This has been captured in https://github.com/WasatchPhotonics/ENLIGHTEN/issues/474
368
+
369
+ auto_enable_laser = tor is not None and tor.enable_laser_before
370
+ # log.debug("acquire_spectrum: auto_enable_laser = %s", auto_enable_laser)
371
+
372
+ dark_reading = SpectrometerResponse()
373
+ if auto_enable_laser and tor.take_dark:
374
+ log.debug(f"AUTO-RAMAN ==> taking internal dark")
375
+
376
+ # disable laser if it was on
377
+ if self.settings.state.laser_enabled:
378
+ log.debug("AUTO-RAMAN ==> disabling laser for internal dark")
379
+ self.hardware.handle_requests([SpectrometerRequest('set_laser_enable', args=[False])])
380
+ time.sleep(1)
381
+
382
+ dark_reading = self.take_one_averaged_reading(label="internal dark")
383
+ if dark_reading.poison_pill or dark_reading.error_msg:
384
+ log.debug(f"internal dark error {dark_reading}")
385
+ acquire_response.poison_pill = True
386
+ return acquire_response
387
+
388
+ if auto_enable_laser:
389
+ log.debug(f"AUTO-RAMAN ==> acquire_spectum: enabling laser")
390
+ req = SpectrometerRequest('set_laser_enable', args=[True])
391
+ self.hardware.handle_requests([req])
392
+ if self.hardware.shutdown_requested:
393
+ log.debug(f"auto_enable_laser shutdown requested")
394
+ acquire_response.poison_pill = True
395
+ return acquire_response
396
+
397
+ if tor:
398
+ log.debug(f"AUTO-RAMAN ==> acquire_spectum: sleeping {tor.laser_warmup_ms}ms for laser to warmup")
399
+ time.sleep(tor.laser_warmup_ms / 1000.0)
400
+
401
+ self.perform_optional_throwaways()
402
+
403
+ ########################################################################
404
+ # Take a Reading (possibly averaged)
405
+ ########################################################################
406
+
407
+ log.debug("taking averaged reading")
408
+ take_one_response = self.take_one_averaged_reading(label="sample (possibly Raman)")
409
+ reading = take_one_response.data
410
+ if take_one_response.poison_pill:
411
+ log.debug(f"floating up take_one_averaged_reading poison pill {take_one_response}")
412
+ return take_one_response
413
+ if take_one_response.keep_alive:
414
+ log.debug(f"floating up keep alive")
415
+ return take_one_response
416
+ if take_one_response.data is None:
417
+ log.debug(f"Received a none reading, floating it up {take_one_response}")
418
+ return take_one_response
419
+
420
+ # don't perform dark subtraction, but pass the dark measurement along
421
+ # with the averaged reading
422
+ if dark_reading.data is not None:
423
+ reading.dark = dark_reading.data.spectrum
424
+ log.debug(f"attaching dark to reading (mean {np.mean(reading.dark)}, min {min(reading.dark)}, max {max(reading.dark)})")
425
+ log.debug(f"reading spectrum (mean {np.mean(reading.spectrum)}, min {min(reading.spectrum)}, max {max(reading.spectrum)})")
426
+
427
+ ########################################################################
428
+ # provide early exit-ramp if we've been asked to return bare Readings
429
+ # (just averaged spectra with corrected bad pixels, no metadata)
430
+ ########################################################################
431
+
432
+ def disable_laser(shutdown=False, label=None):
433
+ if shutdown or auto_enable_laser:
434
+ log.debug(f"acquire_spectrum.disable_laser: shutdown {shutdown}, auto_enable_laser {auto_enable_laser}, label {label}")
435
+ req = SpectrometerRequest('set_laser_enable', args=[False])
436
+ self.hardware.handle_requests([req])
437
+ acquire_response.poison_pill = shutdown
438
+ return acquire_response # for convenience
439
+
440
+ ########################################################################
441
+ # We're done with the (possibly-averaged) spectrum, so we'd like to now
442
+ # disable the automatically-enabled laser, if it was engaged; but before
443
+ # we can do that that, we should take any requested measurements of the
444
+ # laser temperature and photodiode, as those would obviously be invalid-
445
+ # ated if we took them AFTER the laser was off.
446
+ ########################################################################
447
+
448
+ # only read laser temperature if we have a laser
449
+ if self.settings.eeprom.has_laser:
450
+ try:
451
+ # MZ: we might want to do these for auto_enable_laser as well...
452
+ func_attr = [ ('get_laser_enabled', 'laser_enabled'),
453
+ ('can_laser_fire', 'laser_can_fire'),
454
+ ('is_laser_firing', 'laser_is_firing') ]
455
+ if self.settings.is_xs() and self.settings.eeprom.sig_laser_tec:
456
+ func_attr.append( ('get_laser_tec_mode', 'laser_tec_enabled') )
457
+ func_attr.append( ('get_ambient_temperature_degC', 'ambient_temperature_degC') )
458
+
459
+ for (func, attr) in func_attr:
460
+ req = SpectrometerRequest(func)
461
+ res = self.hardware.handle_requests([req])[0]
462
+ if res is None:
463
+ log.debug(f"WasatchDevice.acquire_spectrum: ignoring None {func} response")
464
+ else:
465
+ if res.error_msg != '':
466
+ return res
467
+ value = res.data
468
+ # log.debug(f"WasatchDevice.acquire_spectrum: storing {attr} = {value}")
469
+ setattr(reading, attr, value)
470
+
471
+ if self.hardware.shutdown_requested:
472
+ return disable_laser(shutdown=True, label=f"loading laser attributes")
473
+
474
+ # laser temperature
475
+ count = 2 if self.settings.state.secondary_adc_enabled else 1
476
+ for throwaway in range(count):
477
+ req = SpectrometerRequest('get_laser_temperature_raw')
478
+ res = self.hardware.handle_requests([req])[0]
479
+ if res.error_msg != '':
480
+ return res
481
+ reading.laser_temperature_raw = res.data
482
+ if self.hardware.shutdown_requested:
483
+ return disable_laser(shuttdown=True, label=f"reading laser temperature (throwaway {throwaway} of {count})")
484
+
485
+ req = SpectrometerRequest('get_laser_temperature_degC', args=[reading.laser_temperature_raw])
486
+ res = self.hardware.handle_requests([req])[0]
487
+ if res.error_msg != '':
488
+ return res
489
+ reading.laser_temperature_degC = res.data
490
+ if self.hardware.shutdown_requested:
491
+ return disable_laser(shutdown=True, label=f"reading laser temperature")
492
+
493
+ except:
494
+ log.debug("Error reading laser temperature", exc_info=1)
495
+
496
+ # read secondary ADC if requested
497
+ if self.settings.state.secondary_adc_enabled:
498
+ try:
499
+ req = SpectrometerRequest("select_adc", args=[1])
500
+ self.hardware.handle_requests([req])
501
+ if self.hardware.shutdown_requested:
502
+ return disable_laser(shutdown=True, label="select_adc[1]")
503
+
504
+ for throwaway in range(2):
505
+ req = SpectrometerRequest("get_secondary_adc_raw")
506
+ res = self.hardware.handle_requests([req])[0]
507
+ if res.error_msg != '':
508
+ return res
509
+ reading.secondary_adc_raw = res.data
510
+ if self.hardware.shutdown_requested:
511
+ return disable_laser(shutdown=True, label="get_secondary_adc_raw")
512
+
513
+ req = SpectrometerRequest("get_secondary_adc_calibrated", args =[reading.secondary_adc_raw])
514
+ res = self.hardware.handle_requests([req])[0]
515
+ if res.error_msg != '':
516
+ return res
517
+ reading.secondary_adc_calibrated = res.data
518
+ req = SpectrometerRequest("select_adc", args=[0])
519
+ res = self.hardware.handle_requests([req])[0]
520
+ if res.error_msg != '':
521
+ return res
522
+ if self.hardware.shutdown_requested:
523
+ return disable_laser(shutdown=True, label="select_adc[0]")
524
+
525
+ except:
526
+ log.debug("Error reading secondary ADC", exc_info=1)
527
+
528
+ ########################################################################
529
+ # we've read the laser temperature and photodiode, so we can now safely
530
+ # disable the laser (if we're the one who enabled it)
531
+ ########################################################################
532
+
533
+ disable_laser(label="clean exit")
534
+
535
+ ########################################################################
536
+ # finish collecting any metadata that doesn't require the laser
537
+ ########################################################################
538
+
539
+ # read detector temperature if applicable
540
+ if self.settings.eeprom.has_cooling:
541
+ try:
542
+ req = SpectrometerRequest("get_detector_temperature_raw")
543
+ res = self.hardware.handle_requests([req])[0]
544
+ if res.error_msg != '':
545
+ return res
546
+ reading.detector_temperature_raw = res.data
547
+ if self.hardware.shutdown_requested:
548
+ log.debug("detector_temperature_raw shutdown")
549
+ acquire_response.poison_pill = True
550
+ return acquire_response
551
+
552
+ req = SpectrometerRequest("get_detector_temperature_degC", args=[reading.detector_temperature_raw])
553
+ res = self.hardware.handle_requests([req])[0]
554
+ if res.error_msg != '':
555
+ return res
556
+ reading.detector_temperature_degC = res.data
557
+ if self.hardware.shutdown_requested:
558
+ log.debug("detector_temperature_degC shutdown")
559
+ acquire_response.poison_pill = True
560
+ return acquire_response
561
+
562
+ except:
563
+ log.debug("Error reading detector temperature", exc_info=1)
564
+
565
+ # read ambient temperature if applicable
566
+ if self.settings.is_gen15():
567
+ try:
568
+ # reading.ambient_temperature_degC = self.hardware.get_ambient_temperature_degC()
569
+ pass
570
+ except:
571
+ log.debug("Error reading ambient temperature", exc_info=1)
572
+
573
+ # read battery every 5sec
574
+ if self.settings.eeprom.has_battery:
575
+ if self.settings.state.battery_timestamp is None or (datetime.datetime.now() - self.settings.state.battery_timestamp).total_seconds() > 5:
576
+
577
+ # note that the following 3 requests should actually only generate
578
+ # one USB transaction as raw is cached internally
579
+ req = SpectrometerRequest("get_battery_state_raw")
580
+ res = self.hardware.handle_requests([req])[0]
581
+ if res.error_msg != '':
582
+ return res
583
+ reading.battery_raw = res.data
584
+ if self.hardware.shutdown_requested:
585
+ log.debug("battery_raw shutdown")
586
+ acquire_response.poison_pill = True
587
+ return acquire_response
588
+
589
+ req = SpectrometerRequest("get_battery_percentage")
590
+ res = self.hardware.handle_requests([req])[0]
591
+ if res.error_msg != '':
592
+ return res
593
+ reading.battery_percentage = res.data
594
+ if self.hardware.shutdown_requested:
595
+ log.debug("battery_perc shutdown")
596
+ acquire_response.poison_pill = True
597
+ return acquire_response
598
+ self.last_battery_percentage = reading.battery_percentage
599
+
600
+ req = SpectrometerRequest("get_battery_percentage")
601
+ res = self.hardware.handle_requests([req])[0]
602
+ if res.error_msg != '':
603
+ return res
604
+ reading.battery_charging = res.data
605
+ if self.hardware.shutdown_requested:
606
+ log.debug("battery_charging shutdown")
607
+ acquire_response.poison_pill = True
608
+ return acquire_response
609
+
610
+ log.debug("battery: %.2f%% (%s)", reading.battery_percentage, "charging" if reading.battery_charging else "discharging")
611
+ else:
612
+ if reading is not None:
613
+ reading.battery_percentage = self.last_battery_percentage
614
+
615
+ self.hardware.update_firmware_log()
616
+
617
+ if auto_enable_laser:
618
+ log.debug(f"AUTO-RAMAN ==> done")
619
+
620
+ if tor:
621
+ reading.take_one_request = tor
622
+
623
+ # this was for a "fast BatchCollection" with a streaming multi-reading TakeOneRequest
624
+ if tor.readings_target:
625
+ tor.readings_current += 1
626
+
627
+ if not tor.readings_target or tor.readings_current >= tor.readings_target:
628
+ log.debug(f"completed {tor}")
629
+ self.take_one_request = None
630
+
631
+ log.debug("device.acquire_spectrum: returning %s", reading)
632
+ acquire_response.data = reading
633
+ self.last_complete_acquisition = datetime.datetime.now()
634
+ return acquire_response
635
+
636
+ ##
637
+ # It's unclear how many throwaways are really needed for a stable Raman
638
+ # spectrum, and whether they're really based on number of integrations
639
+ # (sensor stabilization) or time (laser warmup); I suspect both. Also note
640
+ # the potential need for sensor warm-up, but I think that's handled inside FW.
641
+ #
642
+ # Optimal would probably be something like "As many integrations as it takes
643
+ # to span 2sec, but not fewer than two."
644
+ #
645
+ # These are NOT the same throwaways added to smooth spectra over changes to
646
+ # integration time and gain. These are separate throwaways potentially
647
+ # required when waking the sensor from sleep. However, I'm using the same
648
+ # mechanism for tracking (self.remaining_throwaways) for commonality.
649
+ #
650
+ # @todo This shouldn't be required at all if we're in free-running mode, or
651
+ # if it's been less than a second since the last acquisition.
652
+ # @returns SpectrometerResponse IFF error occurred (normally None)
653
+ def perform_optional_throwaways(self):
654
+ if self.settings.is_micro() and self.take_one_request:
655
+ count = 2
656
+ readout_ms = 5
657
+
658
+ # Assume that if we FINISHED the last measurement less than a second
659
+ # ago, the sensor probably has not gone to sleep and doesn't need SW-
660
+ # driven warmups.
661
+ elapsed_sec_since_last_acquisition = (datetime.datetime.now() - self.last_complete_acquisition).total_seconds()
662
+ if self.last_complete_acquisition is None or elapsed_sec_since_last_acquisition > 1:
663
+ # for now, default to 2sec worth of acquisitions
664
+ while count * (self.settings.state.integration_time_ms + readout_ms) < 2000:
665
+ count += 1
666
+
667
+ self.hardware.remaining_throwaways = count
668
+ while self.hardware.remaining_throwaways > 0:
669
+ log.debug(f"more than a second since last measurement, so performing wake-up throwaways ({self.hardware.remaining_throwaways - 1} remaining)")
670
+ req = SpectrometerRequest("get_spectrum")
671
+ res = self.hardware.handle_requests([req])[0]
672
+ if res.error_msg != '':
673
+ return res
674
+
675
+ def take_one_averaged_reading(self, label=None):
676
+ """
677
+ Okay, let's talk about averaging. We only perform averaging as a
678
+ blocking call in Wasatch.PY when given a TakeOneRequest with non-zero
679
+ scans_to_average, typically from ENLIGHTEN's TakeOneFeature (perhaps via
680
+ VCRControls.step, possibly with AutoRaman, or BatchCollection).
681
+
682
+ Otherwise, normally this thread (the background thread owned by a
683
+ WasatchDeviceWrapper object and running WrapperWorker.run) is in "free-
684
+ running" mode, taking spectra just as fast as it can in an endless loop,
685
+ feeding them back to the consumer (ENLIGHTEN) over a queue. To keep that
686
+ pipeline "moving," generally we don't do heavy blocking operations down
687
+ here in the background thread.
688
+
689
+ The new AutoRaman feature makes increased using of scan averaging,
690
+ and again kind of wants to be encapsulated down here. And by implication,
691
+ all TakeOneRequests should really support encapsulated, atomic averaging.
692
+ So the current design is that ATOMIC scans_to_average comes from
693
+ TakeOneRequest, ENLIGHTEN-based averaging in SpectrometerState.
694
+
695
+ @returns Reading on success, true or false on "stop processing" conditions
696
+ """
697
+ take_one_response = SpectrometerResponse()
698
+
699
+ if self.take_one_request:
700
+ scans_to_average = self.take_one_request.scans_to_average # how many (for completion test / division)
701
+ loop_count = scans_to_average # how many to take NOW
702
+ sum_locally = scans_to_average > 1 # whether we should be summing at all
703
+ self.sum_count = 0 # whether we should reset previous sums
704
+ else:
705
+ scans_to_average = self.settings.state.scans_to_average # how many (for completion test / division)
706
+ sum_locally = scans_to_average > 1 # whether we should be summing at all
707
+ loop_count = 1 # how many to take NOW
708
+
709
+ if False:
710
+ log.debug(f"take_one_averaged_reading[{label}]:")
711
+ log.debug(f" self.sum_count = {self.sum_count}")
712
+ log.debug(f" scans_to_average = {scans_to_average}")
713
+ log.debug(f" loop_count = {loop_count}")
714
+ log.debug(f" sum_locally = {sum_locally}")
715
+ log.debug(f" remaining_throwaways = {self.hardware.remaining_throwaways}")
716
+
717
+ # clear any pending throwaways
718
+ while self.hardware.remaining_throwaways > 0:
719
+ log.debug(f"clearing stabilization throwaway ({self.hardware.remaining_throwaways - 1} remaining)")
720
+ req = SpectrometerRequest("get_spectrum")
721
+ res = self.hardware.handle_requests([req])[0]
722
+ if res.error_msg != '':
723
+ return res
724
+
725
+ # either take one measurement (normal), or a bunch (sum_locally)
726
+ reading = None
727
+ for loop_index in range(loop_count):
728
+
729
+ # log.debug(f"take_one_averaged_reading: loop_index {loop_index+1} of {loop_count}")
730
+
731
+ # start a new reading
732
+ # NOTE: reading.timestamp is when reading STARTED, not FINISHED!
733
+ reading = Reading(self.device_id)
734
+
735
+ # TODO...just include a copy of SpectrometerState? something to think
736
+ # about. That would actually provide a reason to roll all the
737
+ # temperature etc readouts into the SpectrometerState class...
738
+ reading.integration_time_ms = self.settings.state.integration_time_ms
739
+ reading.laser_power_perc = self.settings.state.laser_power_perc
740
+ reading.laser_power_mW = self.settings.state.laser_power_mW
741
+ reading.laser_enabled = self.settings.state.laser_enabled
742
+
743
+ # collect ONE spectrum (it's in a while loop because we may have
744
+ # to wait for an external trigger, and must wait through a series
745
+ # of timeouts)
746
+ externally_triggered = self.settings.state.trigger_source == SpectrometerState.TRIGGER_SOURCE_EXTERNAL
747
+ try:
748
+ while True:
749
+ req = SpectrometerRequest("get_spectrum")
750
+ res = self.hardware.handle_requests([req])[0]
751
+ if res.error_msg != '':
752
+ return res
753
+
754
+ # @todo get rid of spectrum_and_row...get_spectrum() can go back to only returning spectrum
755
+ spectrum_and_row = res.data
756
+ if res.poison_pill:
757
+ # float up poison
758
+ take_one_response.transfer_response(res)
759
+ return take_one_response
760
+ if res.keep_alive:
761
+ # float up keep alive
762
+ take_one_response.transfer_response(res)
763
+ return take_one_response
764
+ if isinstance(spectrum_and_row, bool):
765
+ # get_spectrum returned a poison-pill, so flow it upstream
766
+ take_one_response.poison_pill = True
767
+ return take_one_response
768
+
769
+ if self.hardware.shutdown_requested:
770
+ take_one_response.poison_pill = True
771
+ return take_one_response
772
+
773
+ if spectrum_and_row is None or spectrum_and_row.spectrum is None:
774
+ # FeatureIdentificationDevice can return None when waiting
775
+ # on an external trigger.
776
+ log.debug("take_one_averaged_reading: get_spectrum None, sending keepalive for now")
777
+ take_one_response.transfer_response(res)
778
+ return take_one_response
779
+ else:
780
+ break
781
+
782
+ reading.spectrum = spectrum_and_row.spectrum
783
+ reading.timestamp_complete = datetime.datetime.now()
784
+
785
+ log.debug(f"take_one_averaged_reading: got {reading.spectrum[0:9]}")
786
+ except Exception as exc:
787
+ # if we got the timeout after switching from externally triggered back to internal, let it ride
788
+ take_one_response.error_msg = exc
789
+ take_one_response.error_lvl = ErrorLevel.medium
790
+ take_one_response.keep_alive = True
791
+ if externally_triggered:
792
+ log.debug("caught exception from get_spectrum while externally triggered...sending keepalive")
793
+ return take_one_response
794
+
795
+ log.critical("Error reading hardware data", exc_info=1)
796
+ reading.spectrum = None
797
+ reading.failure = str(exc)
798
+
799
+ ####################################################################
800
+ # Aggregate scan averaging
801
+ ####################################################################
802
+
803
+ # It's important to note here that the scan averaging "sum" buffer
804
+ # here (self.summed_spectra) and counter (self.sum_count) are
805
+ # attributes of this WasatchDevice object: they are not part of the
806
+ # Reading being sent back to the caller.
807
+ #
808
+ # The current architecture in Wasatch.PY, which is a little different
809
+ # than other Wasatch drivers, is that even when scan averaging is
810
+ # enabled, EVERY SPECTRUM will be flowed back to the caller.
811
+ #
812
+ # This is because early versions of ENLIGHTEN showed the "individual"
813
+ # (pre-averaged) spectra as a faint grey trace in the background,
814
+ # updating with each integration, showing the higher noise level of
815
+ # "raw" spectra while an on-screen counter showed the incrementing
816
+ # tally of summed spectra in the buffer.
817
+ #
818
+ # Then, when the FINAL raw spectrum had been read and the complete
819
+ # average could be computed, that averaged spectrum was returned
820
+ # with a special flag in the Reading object to indicate "fully
821
+ # averaged."
822
+ #
823
+ # We no longer show the "background" spectral trace in ENLIGHTEN,
824
+ # so this is kind of wasting some intra-process bandwidth, but we
825
+ # do still increment the on-screen tally as feedback while the
826
+ # averaging is taking place, so the in-process Reading messages
827
+ # sent during an averaged collection are not entirely wasted.
828
+ #
829
+ # Still, this is not the way we would have designed a Python driver
830
+ # "from a blank sheet," and our other driver architectures show that.
831
+ #
832
+ # @todo: since we're actually passing every pre-averaged spectrum
833
+ # back to ENLIGHTEN, for non-TakeOneRequest averaging, I feel like
834
+ # we should probably perform the summation in ENLIGHTEN too (for
835
+ # non-TakeOneRequest averages).
836
+
837
+ if not reading.failure:
838
+ # log.debug("take_one_averaged_reading: not failure")
839
+ if sum_locally:
840
+ log.debug("take_one_averaged_reading: summing locally")
841
+ if self.sum_count == 0:
842
+ log.debug("take_one_averaged_reading: first spectrum (initializing)")
843
+ self.summed_spectra = [float(i) for i in reading.spectrum]
844
+ else:
845
+ log.debug("take_one_averaged_reading: adding to previous")
846
+ for i in range(len(self.summed_spectra)):
847
+ self.summed_spectra[i] += reading.spectrum[i]
848
+ self.sum_count += 1
849
+ log.debug("take_one_averaged_reading: summed_spectra : %s ...", self.summed_spectra[0:9])
850
+
851
+ # count spectra
852
+ self.session_reading_count += 1
853
+ reading.session_count = self.session_reading_count
854
+ reading.sum_count = self.sum_count
855
+ log.debug(f"take_one_averaged_reading: reading.sum_count now {reading.sum_count}, session_count {reading.session_count}")
856
+
857
+ # have we completed the averaged reading?
858
+ if sum_locally:
859
+ log.debug(f"take_one_averaged_reading: checking for completion self.sum_count {self.sum_count} >?= scans_to_average {scans_to_average}")
860
+ if self.sum_count >= scans_to_average:
861
+ reading.spectrum = [ x / self.sum_count for x in self.summed_spectra ]
862
+ log.debug("take_one_averaged_reading: averaged_spectrum : %s ...", reading.spectrum[0:9])
863
+ reading.averaged = True
864
+
865
+ # reset for next average
866
+ self.summed_spectra = None
867
+ self.sum_count = 0
868
+ else:
869
+ # if averaging isn't enabled...then a single reading is the
870
+ # "averaged" final measurement (check reading.sum_count to confirm)
871
+ reading.averaged = True
872
+
873
+ log.debug("device.take_one_averaged_reading: returning %s", reading)
874
+ take_one_response.data = reading
875
+ return take_one_response
876
+
877
+ def monitor_memory(self):
878
+ now = datetime.datetime.now()
879
+ if (now - self.last_memory_check).total_seconds() < 5:
880
+ return
881
+
882
+ self.last_memory_check = now
883
+ size_in_bytes = psutil.Process(self.process_id).memory_info().rss
884
+ log.info("monitor_memory: PID %d memory = %d bytes", self.process_id, size_in_bytes)
885
+
886
+ ##
887
+ # Process every entry on the incoming command (settings) queue, writing each
888
+ # to the device.
889
+ #
890
+ # Essentially this iterates through all the (setting, value) pairs we've
891
+ # received through change_setting() which have not yet been processed, and <-- MZ: incomplete sentence
892
+ #
893
+ #
894
+ # Note that WrapperWorker.run "de-dupes" commands on
895
+ # receipt from ENLIGHTEN, so the command stream arising from that source
896
+ # should already be optimized and minimal. Commands injected manually by
897
+ # calling WasatchDevice.change_setting() do not receive this treatment.
898
+ #
899
+ # In the normal multithreaded (ENLIGHTEN) workflow, this function is called
900
+ # at the beginning of acquire_data, itself ticked regularly by
901
+ # WrapperWorker.run.
902
+ def process_commands(self):
903
+ control_object = "throwaway"
904
+ retval = False
905
+ log.debug("process_commands: processing")
906
+ while len(self.command_queue) > 0:
907
+ control_object = self.command_queue.pop(0)
908
+ log.debug("process_commands: %s", control_object)
909
+
910
+ # is this a command used by WasatchDevice itself, and not
911
+ # passed down to FeatureIdentificationDevice?
912
+ if control_object.setting.lower() == "acquire":
913
+ # MZ: does this ever happen? It looks like the current
914
+ # request sent down from WrapperWorker is "acquire_data"
915
+ # not "acquire"...
916
+ log.debug("process_commands: acquire found")
917
+ retval = True
918
+ else:
919
+ # send setting downstream to be processed by the spectrometer HAL
920
+ # (probably FeatureIdentificationDevice)
921
+ req = SpectrometerRequest(control_object.setting, args=[control_object.value])
922
+ self.hardware.handle_requests([req])
923
+
924
+ return retval
925
+
926
+ def _init_process_funcs(self): # -> dict[str, Callable[..., Any]]
927
+ process_f = {}
928
+
929
+ process_f["connect"] = self.connect
930
+ process_f["disconnect"] = self.disconnect
931
+ process_f["acquire_data"] = self.acquire_data
932
+
933
+ return process_f
934
+
935
+ # ######################################################################## #
936
+ # #
937
+ # Area Scan #
938
+ # #
939
+ # ######################################################################## #
940
+
941
+ def acquire_area_scan(self):
942
+ """
943
+ FeatureIdentificationDevice.get_area_scan returns a Reading because it
944
+ really needs to return both the AreaScanImage (whatever form that may
945
+ take) and the vertically-binned spectrum (for live display).
946
+
947
+ @returns a SpectrometerResponse(data=Reading)
948
+ """
949
+ reading = self.hardware.get_area_scan()
950
+ self.session_reading_count += 1
951
+ reading.session_count = self.session_reading_count
952
+ return SpectrometerResponse(data=reading)
953
+
954
+ # ######################################################################## #
955
+ # #
956
+ # BalanceAcquisition #
957
+ # #
958
+ # ######################################################################## #
959
+
960
+ def balance_acquisition(self,
961
+ device = None,
962
+ mode = None,
963
+ intensity = 45000,
964
+ threshold = 2500,
965
+ pixel = None,
966
+ max_integration_time_ms = 5000,
967
+ max_tries = 20):
968
+ balancer = BalanceAcquisition(
969
+ device = self,
970
+ mode = mode,
971
+ intensity = intensity,
972
+ threshold = threshold,
973
+ pixel = pixel,
974
+ max_integration_time_ms = max_integration_time_ms,
975
+ max_tries = max_tries)
976
+ return balancer.balance()
977
+
978
+ # ######################################################################## #
979
+ # #
980
+ # Hardware Control #
981
+ # #
982
+ # ######################################################################## #
983
+
984
+ ##
985
+ # Processes an incoming (setting, value) pair.
986
+ #
987
+ # Some settings are processed internally within this function, if the
988
+ # functionality they are controlling is implemented by WasatchDevice.
989
+ # This includes scan averaging, and anything related to scan averaging
990
+ # (such as "take one" behavior).
991
+ #
992
+ # Most tuples are queued to be sent downstream to the connected hardware
993
+ # (usually FeatureIdentificationDevice) at the start of the next
994
+ # acquisition.
995
+ #
996
+ # Some hardware settings (those involving triggering or the laser) are
997
+ # sent downstream immediately, rather than waiting for the next "scheduled"
998
+ # settings update.
999
+ #
1000
+ # ENLIGHTEN commands to WasatchDeviceWrapper are sent here by
1001
+ # WasatchDeviceWrapper.continuous_poll.
1002
+ #
1003
+ # @param setting (Input) which setting to change
1004
+ # @param value (Input) the new value of the setting (required, but can
1005
+ # be None or "anything" for commands like "acquire" which
1006
+ # don't use the argument).
1007
+ # @param allow_immediate
1008
+ def change_setting(self, setting: str, value: Any, allow_immediate: bool = True): # -> None
1009
+ log.debug(f"WasatchDevice.change_setting: {setting} -> {value}")
1010
+
1011
+ # Since scan averaging lives in WasatchDevice, handle commands which affect
1012
+ # averaging at this level
1013
+ if setting == "scans_to_average":
1014
+ self.sum_count = 0
1015
+ self.settings.state.scans_to_average = int(value)
1016
+ return
1017
+ elif setting == "reset_scan_averaging":
1018
+ self.sum_count = 0
1019
+ return
1020
+ elif setting == "take_one_request":
1021
+ self.sum_count = 0
1022
+ self.take_one_request = value
1023
+ return
1024
+ elif setting == "cancel_take_one":
1025
+ self.sum_count = 0
1026
+ self.take_one_request = None
1027
+ return
1028
+
1029
+ control_object = ControlObject(setting, value)
1030
+ self.command_queue.append(control_object)
1031
+ log.debug("change_setting: queued %s", control_object)
1032
+
1033
+ # always process trigger_source commands promptly (can't wait for end of
1034
+ # acquisition which may never come)
1035
+ if (allow_immediate and self.immediate_mode) or re.search(r"trigger|laser", setting):
1036
+ log.debug("immediately processing %s", control_object)
1037
+ self.process_commands()
1038
+
1039
+ # override handle_requests. I didn't see a good way to overwrite change_setting without
1040
+ # having to redo that pass through.
1041
+ def handle_requests(self, requests: list[SpectrometerRequest]): # -> list[SpectrometerResponse]
1042
+ responses = []
1043
+ for request in requests:
1044
+ try:
1045
+ cmd = request.cmd
1046
+ proc_func = self.process_f.get(cmd, None)
1047
+ if proc_func is None:
1048
+ try:
1049
+ self.change_setting(cmd, *request.args, **request.kwargs)
1050
+ except Exception as e:
1051
+ log.error(f"error {e} with trying to set setting {cmd} with args and kwargs {request.args} and {request.kwargs}", exc_info=1)
1052
+ return []
1053
+ elif request.args == [] and request.kwargs == {}:
1054
+ responses.append(proc_func())
1055
+ else:
1056
+ responses.append(proc_func(*request.args, **request.kwargs))
1057
+ except Exception as e:
1058
+ log.error(f"error in handling request {request} of {e}", exc_info=1)
1059
+ responses.append(SpectrometerResponse(error_msg="error processing cmd", error_lvl=ErrorLevel.medium))
1060
+ return responses