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,3961 @@
1
+ import platform
2
+ import datetime
3
+ import logging
4
+ import random
5
+ import numpy as np
6
+ import copy
7
+ import math
8
+ import os
9
+ import re
10
+
11
+ from random import randint
12
+ from time import sleep
13
+
14
+ from . import utils
15
+
16
+ from .SpectrometerSettings import SpectrometerSettings
17
+ from .SpectrometerResponse import SpectrometerResponse, ErrorLevel
18
+ from .SpectrometerRequest import SpectrometerRequest
19
+ from .SpectrometerState import SpectrometerState
20
+ from .InterfaceDevice import InterfaceDevice
21
+ from .DetectorRegions import DetectorRegions
22
+ from .ControlObject import ControlObject
23
+ from .StatusMessage import StatusMessage
24
+ from .RealUSBDevice import RealUSBDevice
25
+ from .MockUSBDevice import MockUSBDevice
26
+ from .AreaScanImage import AreaScanImage
27
+ from .DetectorROI import DetectorROI
28
+ from .PollStatus import PollStatus
29
+ from .Reading import Reading
30
+ from .EEPROM import EEPROM
31
+ from .IMX385 import IMX385
32
+ from .ROI import ROI
33
+
34
+ log = logging.getLogger(__name__)
35
+
36
+ MICROSEC_TO_SEC = 0.000001
37
+ UNINITIALIZED_TEMPERATURE_DEG_C = -999
38
+
39
+ class SpectrumAndRow:
40
+ def __init__(self, spectrum=None, row=-1):
41
+ self.spectrum = None
42
+ self.row = row
43
+
44
+ if spectrum is not None:
45
+ self.spectrum = spectrum.copy()
46
+
47
+ class FeatureIdentificationDevice(InterfaceDevice):
48
+ """
49
+ This is the basic implementation of our FeatureIdentificationDevice (FID)
50
+ spectrometer USB API as defined in ENG-0001.
51
+ This class is roughly comparable to Wasatch.NET's Spectrometer.cs.
52
+
53
+ This class is normally not accessed directly, but through the higher-level
54
+ abstraction WasatchDevice.
55
+
56
+ @see ENG-0001
57
+ ##########################################################################
58
+ This class adopts the external device interface structure
59
+ This invlovles receiving a request through the handle_request function
60
+ A request is processed based on the key in the request
61
+ The processing function passes the commands to the requested device
62
+ Once it recevies a response from the connected device it then passes that
63
+ back up the chain
64
+ @verbatim
65
+ Enlighten Request
66
+ |
67
+ handle_requests
68
+ |
69
+ ------------
70
+ / / | \ \
71
+ { get_laser status, acquire, set_laser_watchdog, etc....}
72
+ \ \ | / /
73
+ ------------
74
+ |
75
+ _send_code
76
+ @endverbatim
77
+ ############################################################################
78
+ """
79
+
80
+ # ##########################################################################
81
+ # Lifecycle
82
+ # ##########################################################################
83
+
84
+ def __init__(self, device_id, message_queue=None, alert_queue=None):
85
+ """
86
+ Instantiate a FeatureIdentificationDevice with from the given device_id.
87
+ @param device_id [in] device ID ("USB:0x24aa:0x1000:1:24")
88
+ @param message_queue [in] if provided, provides an outbound (from FID
89
+ to WrapperWorker) queue for writing StatusMessage objects upstream
90
+ @param alert_queue [in] if provided, accepts an inbound (from
91
+ WrapperWorker to FID) queue for receiving AlertMessage objects
92
+ """
93
+ super().__init__()
94
+ self.device_id = device_id
95
+ self.message_queue = message_queue
96
+ self.alert_queue = alert_queue
97
+
98
+ self.device = None
99
+ if "MOCK" in str(device_id).upper():
100
+ self.device_type = MockUSBDevice(device_id.name,
101
+ device_id.directory,
102
+ device_id.overrides,
103
+ device_id.spectra_options)
104
+ else:
105
+ self.device_type = RealUSBDevice(device_id)
106
+
107
+ self.last_usb_timestamp = None
108
+
109
+ self.laser_temperature_invalid = False
110
+ self.ccd_temperature_invalid = False
111
+
112
+ self.settings = SpectrometerSettings(device_id)
113
+ self.eeprom_backup = None
114
+
115
+ self.alerts = set()
116
+
117
+ # ######################################################################
118
+ # these are "driver state" within FeatureIdentificationDevice, and don't
119
+ # really relate to the spectrometer hardware
120
+ # ######################################################################
121
+
122
+ self.detector_tec_setpoint_has_been_set = False
123
+ self.last_applied_laser_power = 0.0 # last power level APPLIED to laser, either by turning off (0) or on
124
+ self.next_applied_laser_power = None # power level to be applied NEXT time the laser is enabled
125
+ self.has_received_spectrum = False
126
+ self.extra_area_scan_data = []
127
+
128
+ self.raise_exceptions = False
129
+ self.inject_random_errors = False
130
+ self.random_error_perc = 0.001 # 0.1%
131
+ self.allow_default_gain_reset = True
132
+
133
+ self.connected = False
134
+ self.connecting = False
135
+ self.shutdown_requested = False
136
+
137
+ self.last_spectrum = None
138
+ self.prev_pixels = None
139
+
140
+ # in case of I2C collisions within the spectrometer, e.g. due to battery-LED status
141
+ self.retry_enabled = True
142
+ self.retry_ms = 5
143
+ self.retry_max = 3
144
+
145
+ self.process_f = self._init_process_funcs()
146
+ self.imx385 = IMX385()
147
+
148
+ def handle_requests(self, requests: list[SpectrometerRequest]):
149
+ """
150
+ @todo consider making 'requests' an object, and dynamically checking to
151
+ see if it is a single SpectrometerRequest or a list[SpectrometerRequest];
152
+ if the former, only return a single SpectrometerResponse.
153
+ """
154
+ responses = []
155
+ for request in requests:
156
+ try:
157
+ cmd = request.cmd
158
+ proc_func = self.process_f.get(cmd, None)
159
+ if proc_func is None:
160
+ responses.append(SpectrometerResponse(error_msg=f"unsupported cmd {request.cmd}", error_lvl=ErrorLevel.low))
161
+ elif request.args == [] and request.kwargs == {}:
162
+ responses.append(proc_func())
163
+ else:
164
+ responses.append(proc_func(*request.args, **request.kwargs))
165
+ except Exception as e:
166
+ log.error(f"error in handling request {request} of {e}", exc_info=1)
167
+ responses.append(SpectrometerResponse(error_msg="error processing cmd", error_lvl=ErrorLevel.medium))
168
+ return responses
169
+
170
+ def connect(self, retries=0):
171
+ """
172
+ Connect to the device and initialize basic settings.
173
+ @warning this causes a problem in non-blocking mode (WasatchDeviceWrapper)
174
+ on MacOS
175
+ """
176
+ self.connecting = True
177
+
178
+ # ######################################################################
179
+ # USB Connection
180
+ # ######################################################################
181
+
182
+ # Generate a fresh listing of USB devices with the requested VID and PID.
183
+ # Note that this is NOT how WasatchBus traverses the list. It actually
184
+ # calls usb.busses(), then iterates over bus.devices, but that's because
185
+ # it doesn't know what PIDs it might be looking for. We know, so just
186
+ # narrow down the search to those devices.
187
+
188
+ log.info(f"FID.connect: asked to connect to device_type {self.device_type}")
189
+ log.info(f"FID.connect: calling device_type.find, looking for VID 0x{self.device_id.vid:04x} and PID 0x{self.device_id.pid:04x}")
190
+ devices = self.device_type.find(find_all=True, idVendor=self.device_id.vid, idProduct=self.device_id.pid)
191
+ log.info(f"FID.connect: found devices {devices}")
192
+ dev_list = list(devices) # convert from array
193
+
194
+ device = None
195
+ log.info(f"searching for specified device in dev_list {dev_list}")
196
+ for dev in dev_list:
197
+ if dev.bus != self.device_id.bus:
198
+ log.debug("FID.connect: rejecting device (bus %d != requested %d)", dev.bus, self.device_id.bus)
199
+ elif dev.address != self.device_id.address:
200
+ log.debug("FID.connect: rejecting device (address %d != requested %d)", dev.address, self.device_id.address)
201
+ else:
202
+ device = dev
203
+ break
204
+
205
+ if device is None:
206
+ log.debug(f"FID.connect: unable to find DeviceID {self.device_id}")
207
+ self.connecting = False
208
+ return SpectrometerResponse(data=False, error_msg=f"unable to find DeviceID {self.device_id}")
209
+ else:
210
+ log.debug("FID.connect: matched DeviceID %s", str(self.device_id))
211
+
212
+ if os.name != "posix":
213
+ log.debug("on Windows, so NOT setting configuration and claiming interface")
214
+ elif "macOS" in platform.platform():
215
+ log.debug("on MacOS, so NOT setting configuration and claiming interface")
216
+ # elif "raspberrypi" in str(os.uname()):
217
+ # log.debug("on Raspberry Pi, so NOT setting configuration and claiming interface")
218
+ else:
219
+ log.debug("on posix, so setting configuration and claiming interface")
220
+
221
+ # in the following, return SpectrometerResponse objects rather than
222
+ # raising exceptions so the user will have a more-useful error
223
+ # message to report or use in troubleshooting (exception still gets
224
+ # logged)
225
+ try:
226
+ log.debug("setting configuration")
227
+ self.device_type.set_configuration(device)
228
+ except Exception as exc:
229
+ #####################################################################################################################
230
+ # This additional if statement is present for the Raspberry Pi. There is an issue with resource busy errors.
231
+ # Adding dev.reset() solves this. See https://stackoverflow.com/questions/29345325/raspberry-pyusb-gets-resource-busy
232
+ #####################################################################################################################
233
+ if "Resource busy" in str(exc) and retries <= 3:
234
+ log.warn("Hardware Failure in setConfiguration. Resource busy error. Attempting to reattach driver by reset.")
235
+ self.device_type.reset(dev)
236
+ sleep_ms = 10 ** retries # 10^3 ms = 1sec max delay
237
+ sleep(sleep_ms / 1000.0)
238
+ return self.connect(retries=retries+1)
239
+
240
+ self.connecting = False
241
+ msg = f"Hardware Failure in setConfiguration, giving up after {retries} retries"
242
+ log.critical(msg, exc_info=1)
243
+ return SpectrometerResponse(False, error_msg=msg)
244
+
245
+ try:
246
+ log.debug("claiming interface")
247
+ self.device_type.claim_interface(device, 0)
248
+ except:
249
+ self.connecting = False
250
+ msg = "Hardware Failure in claimInterface"
251
+ log.critical(msg, exc_info=1)
252
+ return SpectrometerResponse(False, error_msg=msg)
253
+
254
+ self.device = device
255
+
256
+ return self._post_connect()
257
+
258
+ def _post_connect(self):
259
+ """
260
+ Perform additional setup after instantiating FID device.
261
+ Split-out from physical / bus connect() to simplify MockSpectrometer.
262
+ """
263
+
264
+ # grab firmware versions early (and capture in debug log)
265
+ self.get_microcontroller_firmware_version()
266
+ log.debug(f"Microcontroller firmware version {self.settings.microcontroller_firmware_version}")
267
+
268
+ self.get_fpga_firmware_version()
269
+ log.debug(f"FPGA firmware version {self.settings.fpga_firmware_version}")
270
+
271
+ # self.get_microcontroller_serial_number()
272
+ # log.debug(f"Microcontroller serial number {self.settings.microcontroller_serial_number}")
273
+
274
+ # issue: BL652 may not be fully booted if this was a hotplug. We could
275
+ # of course re-poll if None, but the initial SpectrometerSettings
276
+ # will already have been used to populate the EEPROMEditor. What
277
+ # we really need to do is send a "change" in this setting upstream
278
+ # via the MessageQueue, and have a listener in ENLIGHTEN/caller
279
+ # for such updates. The way we mainly do this now is by adding
280
+ # fields to Reading (like temperature, battery, laser interlock
281
+ # etc), since most dynamic (uncommanded) changes in spectrometer
282
+ # state are usually measurement-related. It would be a little
283
+ # weird to add a "dynamic firmware version" to Reading, implying
284
+ # that firmware versions might suddenly change mid-runtime...
285
+ # self.get_ble_firmware_version()
286
+ # log.debug(f"BLE firmware version {self.settings.ble_firmware_version}")
287
+
288
+ # ######################################################################
289
+ # model-specific settings
290
+ # ######################################################################
291
+
292
+ log.debug("model-specific settings")
293
+
294
+ # default high-gain mode for InGaAs
295
+ if self.settings.is_ingaas():
296
+ self.set_high_gain_mode_enable(True)
297
+
298
+ # ######################################################################
299
+ # EEPROM
300
+ # ######################################################################
301
+
302
+ log.debug("reading EEPROM")
303
+
304
+ result = self._read_eeprom()
305
+ if not result.data:
306
+ log.error(f"failed to read EEPROM, got error message of {result.error_msg}")
307
+ self.connecting = False
308
+ return result
309
+
310
+ # ######################################################################
311
+ # Automatically disable laser at connection
312
+ # ######################################################################
313
+
314
+ # if self.settings.eeprom.has_laser:
315
+ # self.set_laser_enable(False)
316
+
317
+ # ######################################################################
318
+ # TEC Setpoint
319
+ # ######################################################################
320
+
321
+ if self.settings.is_xs():
322
+ if self.settings.eeprom.has_laser:
323
+ if self.settings.eeprom.sig_laser_tec:
324
+
325
+ # check the new location
326
+ setpoint = self.settings.eeprom.startup_laser_tec_setpoint # prefer new
327
+ if not setpoint:
328
+ setpoint = self.settings.eeprom.startup_temp_degC # default to old
329
+
330
+ # sanity-check for reasonable setpoint range (raw 12-bit)
331
+ if 700 <= setpoint <= 900:
332
+ log.debug("initializing XS laser TEC setpoint")
333
+
334
+ # kludge: for now, use the detector TEC startup setpoint for laser
335
+ self.settings.state.laser_tec_setpoint = setpoint
336
+ self.set_laser_temperature_setpoint_raw(self.settings.state.laser_tec_setpoint)
337
+
338
+ # this should be the default in firmware, but set anyway
339
+ log.debug("initializing XS laser TEC mode -> AUTO")
340
+ self.set_laser_tec_mode("AUTO")
341
+ else:
342
+ # don't set anything if default setpoint looks way off
343
+ log.error(f"laser TEC setpoint looks invalid: {self.settings.eeprom.startup_temp_degC}")
344
+
345
+ # load this for software-based Auto-Raman timing
346
+ log.debug("initializing laser warning delay")
347
+ self.get_laser_warning_delay_sec()
348
+
349
+ # ######################################################################
350
+ # Detector TEC
351
+ # ######################################################################
352
+
353
+ if not self.settings.is_xs():
354
+
355
+ # X/XM models don't require runtime configuration of the laser TEC
356
+ # (it's set via pots on 110280 (SML) or 110613 (MML)), but do need
357
+ # to initialize the detector TEC for R and C units.
358
+ if self.settings.eeprom.has_cooling:
359
+
360
+ degC = None
361
+ eeprom = self.settings.eeprom
362
+
363
+ if eeprom.min_temp_degC <= eeprom.startup_temp_degC <= eeprom.max_temp_degC:
364
+ degC = eeprom.startup_temp_degC
365
+ elif re.match(r"7031|10141|9214", eeprom.detector):
366
+ degC = -15
367
+ elif re.match(r"16011|11511|11850|13971", eeprom.detector):
368
+ degC = 10
369
+
370
+ if degC is not None:
371
+ # TEC doesn't do anything unless you give it a temperature first
372
+ log.debug(f"setting detector TEC setpoint to {degC} deg C")
373
+ self.detector_tec_setpoint_degC = degC
374
+ self.set_detector_tec_setpoint_degC(self.detector_tec_setpoint_degC)
375
+
376
+ log.debug("enabling detector TEC")
377
+ self.detector_tec_setpoint_has_been_set = True
378
+ self.set_tec_enable(True)
379
+
380
+ # ######################################################################
381
+ # FPGA
382
+ # ######################################################################
383
+
384
+ log.debug("reading FPGA compilation options")
385
+ self._read_fpga_compilation_options()
386
+
387
+ log.debug("configuring FPGA")
388
+
389
+ # automatically push EEPROM values to the FPGA (on modern EEPROMs)
390
+ # (this will work on Series-XS as well, even if we subsequently track its gain
391
+ # somewhat differently as state.gain_db)
392
+ if self.settings.eeprom.format >= 4:
393
+ log.debug("sending gain/offset to FPGA")
394
+ self.set_detector_gain (self.settings.eeprom.detector_gain)
395
+ self.set_detector_offset (self.settings.eeprom.detector_offset)
396
+ self.set_detector_gain_odd (self.settings.eeprom.detector_gain_odd)
397
+ self.set_detector_offset_odd(self.settings.eeprom.detector_offset_odd)
398
+
399
+ # initialize state.gain_db from EEPROM startup value
400
+ self.settings.state.gain_db = self.settings.eeprom.detector_gain
401
+
402
+ self.update_vertical_roi()
403
+
404
+ # ######################################################################
405
+ # post-connection defaults
406
+ # ######################################################################
407
+
408
+ # default to internal triggering
409
+ self.set_trigger_source(SpectrometerState.TRIGGER_SOURCE_INTERNAL)
410
+
411
+ if self.settings.is_xs():
412
+
413
+ if self.settings.eeprom.has_laser:
414
+ has_sml = self.settings.eeprom.has_sml()
415
+ sig_laser_tec = self.settings.eeprom.sig_laser_tec
416
+ log.debug(f"is_xs {self.settings.is_xs()}, has_laser {self.settings.eeprom.has_laser}, has_sml {has_sml}, sig_laser_tec {sig_laser_tec}")
417
+
418
+ # laser watchdog
419
+ sec = self.settings.eeprom.laser_watchdog_sec
420
+ if sec <= 0 and has_sml and not sig_laser_tec:
421
+ sec = EEPROM.DEFAULT_LASER_WATCHDOG_SEC
422
+ log.debug(f"declining to disable laser watchdog for SML w/o TEC, defaulting to {sec}sec")
423
+ log.debug(f"post-connect: initializing laser watchdog to {sec}sec")
424
+ self.set_laser_watchdog_sec(sec)
425
+
426
+ self.set_integration_time_ms(self.settings.eeprom.startup_integration_time_ms)
427
+
428
+ # # for now, enable Gen 1.5 accessory connector by default
429
+ # if self.settings.is_gen15():
430
+ # log.debug("enabling Gen 1.5 accessory connector")
431
+ # self.set_accessory_enable(True)
432
+
433
+ # ######################################################################
434
+ # Area Scan
435
+ # ######################################################################
436
+
437
+ if self.settings.is_xs():
438
+ self.settings.eeprom.actual_pixels_vertical = 1080 # MZ: probably higher
439
+ else:
440
+ self.settings.eeprom.actual_pixels_vertical = 70
441
+
442
+ self.reset_area_scan_frame()
443
+
444
+ # ######################################################################
445
+ # Done
446
+ # ######################################################################
447
+
448
+ log.debug("connection successful")
449
+ self.connected = True
450
+ self.connecting = False
451
+
452
+ self.settings.state.dump("FID.post_connect")
453
+
454
+ return SpectrometerResponse(self.connected)
455
+
456
+ def reset_area_scan_frame(self):
457
+ self.area_scan_frame = [ [0] * self.settings.pixels() for line in range(self.settings.eeprom.actual_pixels_vertical) ]
458
+
459
+ def disconnect(self):
460
+ if self.last_applied_laser_power:
461
+ log.debug("fid.disconnect: disabling laser")
462
+ self._set_laser_enable_immediate(False)
463
+
464
+ self.connected = False
465
+
466
+ log.critical("fid.disconnect: releasing interface")
467
+ try:
468
+ #result = self.device_type.release_interface(self.device, 0)
469
+ try:
470
+ self.device_type.reset(self.device)
471
+ except:
472
+ log.error("Couldn't reset device")
473
+ except:
474
+ log.warn("Failure in release interface", exc_info=1)
475
+ raise
476
+ return SpectrometerResponse(True)
477
+
478
+ def _schedule_disconnect(self, exc):
479
+ """
480
+ Something in the driver has caused it to request the controlling
481
+ application to close the peripheral. The next time
482
+ WasatchDevice.acquire_data is called, it will pass a "poison pill" back
483
+ up the response queue.
484
+ Alternately, non-ENLIGHTEN callers can set "raise_exceptions" -> True for
485
+ in-process exception-handling.
486
+ """
487
+ if self.raise_exceptions:
488
+ log.critical("_schedule_disconnect: raising exception %s", exc)
489
+ raise exc
490
+ else:
491
+ log.critical("requesting shutdown due to exception %s", exc)
492
+ self.shutdown_requested = True
493
+
494
+ def reset(self, *args):
495
+ log.debug("FID performing device reset")
496
+ #self.device_type.release_interface(self.device, 0)
497
+ self.device_type.reset(self.device)
498
+ log.debug(f"freed interface")
499
+ '''
500
+ pyusb_devices = list(self.device_type.find(find_all=True,
501
+ idVendor=self.device_id.vid,
502
+ idProduct=self.device_id.pid,
503
+ custom_match = lambda d: d.address == self.device_id.address and d.bus == self.device_id.bus))
504
+ # a couple notes,
505
+ # When you search how to do a reset by enable/disable on windows what comes up is devcon
506
+ # devcon links to pnputil as the recommended tool to use and comes preinstalled on windows
507
+ # pnputil needs the instance ID, which is pretty well hidden in pyusb
508
+ # I had to look at the full configuration string and saw that the way to get the instance id was the following
509
+ # using _try_get_string https://github.com/pyusb/pyusb/blob/master/usb/core.py#L1221
510
+ # so techincally we shouldn't do this by the _ meaning it should be private, but it's the only way I see
511
+ device_instance_id = f'USB\VID_{self.device_id.vid:04X}&PID_{self.device_id.pid:04X}\{usb.core._try_get_string(pyusb_devices[0], pyusb_devices[0].iSerialNumber)}'
512
+ log.debug(f"In reset and restart trying to reset instance id {device_instance_id}")
513
+ subprocess.run(["pnputil", r"/reboot", r"/disable-device", device_instance_id])
514
+ subprocess.run(["pnputil", r"/reboot", r"/enable-device", device_instance_id])
515
+ '''
516
+ # ##########################################################################
517
+ # Utility Methods
518
+ # ##########################################################################
519
+
520
+
521
+ def _to40bit(self, val):
522
+ """
523
+ Laser modulation and continuous-strobe commands take arguments in micro-
524
+ seconds as 40-bit values, where the least-significant 16 bits are passed
525
+ as wValue, the next-significant 16 as wIndex, and the most-significant
526
+ as a single byte of payload. This function takes an unsigned integral
527
+ value (presumably microseconds) and returns a tuple of wValue, wIndex
528
+ and a buffer to pass as payload.
529
+ """
530
+ lsw = val & 0xffff
531
+ msw = (val >> 16) & 0xffff
532
+ buf = [ (val >> 32) & 0xff ] + [0] * 7
533
+ return (lsw, msw, buf)
534
+
535
+ def _wait_for_usb_available(self):
536
+ """
537
+ Wait until any enforced USB packet intervals have elapsed. This does
538
+ nothing in most cases - the function is normally a no-op.
539
+ However, if the application has defined min/max_usb_interval_ms (say
540
+ (20, 50ms), then pick a random delay in the defined window (e.g. 37ms)
541
+ and sleep until it has been at least that long since the last USB
542
+ exchange.
543
+ The purpose of this function was to wring-out some early ARM micro-
544
+ controllers with apparent timing issues under high-speed USB 2.0, to see
545
+ if communications issues disappeared if we enforced a communication
546
+ latency from the software side.
547
+ """
548
+ if self.settings.state.max_usb_interval_ms <= 0:
549
+ return
550
+
551
+ if self.last_usb_timestamp is not None:
552
+ delay_ms = randint(self.settings.state.min_usb_interval_ms, self.settings.state.max_usb_interval_ms)
553
+ next_usb_timestamp = self.last_usb_timestamp + datetime.timedelta(milliseconds=delay_ms)
554
+ now = datetime.datetime.now()
555
+ if now < next_usb_timestamp:
556
+ sleep_sec = (next_usb_timestamp - now).total_seconds()
557
+ log.debug("fid: sleeping %.3f sec to enforce %d ms USB interval", sleep_sec, delay_ms)
558
+ sleep(sleep_sec)
559
+ self.last_usb_timestamp = datetime.datetime.now()
560
+
561
+ def _check_for_random_error(self):
562
+ """
563
+ This function is provided to simulate random USB communication errors
564
+ during regression testing, and is normally a no-op.
565
+ """
566
+ if not self.inject_random_errors:
567
+ return False
568
+
569
+ if random.random() <= self.random_error_perc:
570
+ log.critical("Randomly-injected error")
571
+ self._schedule_disconnect(Exception("Randomly-injected error"))
572
+ return True
573
+ return False
574
+
575
+ ##
576
+ # Until support for even/odd InGaAs gain and offset have been added to the
577
+ # firmware, apply the correction in software.
578
+ def _correct_ingaas_gain_and_offset(self, spectrum: list[float]):
579
+ if not self.settings.is_ingaas() or self.settings.eeprom.hardware_even_odd:
580
+ return False
581
+
582
+ # if even and odd pixels have the same settings, there's no point in doing anything
583
+ if self.settings.eeprom.detector_gain_odd == self.settings.eeprom.detector_gain and \
584
+ self.settings.eeprom.detector_offset_odd == self.settings.eeprom.detector_offset:
585
+ return False
586
+
587
+ if 0 == self.settings.eeprom.detector_gain:
588
+ log.debug("declining to attempt division by zero")
589
+ return
590
+
591
+ log.debug("rescaling InGaAs odd pixels from even gain %.4f, offset %d to odd gain %.4f, offset %d",
592
+ self.settings.eeprom.detector_gain,
593
+ self.settings.eeprom.detector_offset,
594
+ self.settings.eeprom.detector_gain_odd,
595
+ self.settings.eeprom.detector_offset_odd)
596
+
597
+ log.debug("before: %d, %d, %d, %d, %d", spectrum[0], spectrum[1], spectrum[2], spectrum[3], spectrum[4])
598
+
599
+ # iterate over the ODD pixels of the spectrum
600
+ for i in range(1, len(spectrum), 2):
601
+
602
+ # back-out the incorrectly applied "even" gain and offset
603
+ old = float(spectrum[i])
604
+ raw = (old - self.settings.eeprom.detector_offset) / self.settings.eeprom.detector_gain
605
+
606
+ # apply the correct "odd" gain and offset
607
+ spectrum[i] = (raw * self.settings.eeprom.detector_gain_odd) + self.settings.eeprom.detector_offset_odd
608
+
609
+ if i < 5 or i > len(spectrum) - 5:
610
+ log.debug(" pixel %4d: old %.2f raw %.2f new %.2f", i, old, raw, spectrum[i])
611
+
612
+ log.debug("after: %d, %d, %d, %d, %d", spectrum[0], spectrum[1], spectrum[2], spectrum[3], spectrum[4])
613
+
614
+ return True
615
+
616
+ def _apply_linear_pixel_calibration(self, spectrum):
617
+ if not self.settings.linear_pixel_calibration:
618
+ return
619
+
620
+ (slopes, offsets) = self.settings.linear_pixel_calibration
621
+ smoothed = [] # could be faster in Numpy
622
+ for i, intensity in enumerate(spectrum):
623
+ smoothed.append(intensity * slopes[i] + offsets[i])
624
+ return smoothed
625
+
626
+ def _apply_horizontal_binning(self, spectrum):
627
+ if not self.settings.eeprom.horiz_binning_enabled:
628
+ return spectrum
629
+
630
+ mode = self.settings.eeprom.multi_wavelength_calibration.get("horiz_binning_mode")
631
+ if mode == IMX385.BIN_2X2:
632
+ return self.imx385.bin_2x2(spectrum)
633
+ elif mode == IMX385.CORRECT_SSC:
634
+ return self.imx385.correct_ssc(spectrum, self.settings.wavelengths)
635
+ elif mode == IMX385.CORRECT_SSC_BIN_2X2:
636
+ spectrum = self.imx385.correct_ssc(spectrum, self.settings.wavelengths)
637
+ return self.imx385.bin_2x2(spectrum)
638
+ elif mode == IMX385.BIN_4X2:
639
+ return self.imx385.bin_4x2(spectrum)
640
+ elif mode == IMX385.BIN_4X2_INTERP:
641
+ return self.imx385.bin_4x2_interp(spectrum, self.settings.wavelengths)
642
+ elif mode == IMX385.BIN_4X2_AVG:
643
+ return self.imx385.bin_4x2_avg(spectrum)
644
+ else:
645
+ # there may be legacy units in the field where this byte is
646
+ # uninitialized to 0xff...treat as 0x00 for now
647
+ log.error("invalid horizontal binning mode {mode}...defaulting to bin_2x2")
648
+ return self.imx385.bin_2x2(spectrum)
649
+
650
+ def _correct_bad_pixels(self, spectrum):
651
+ """
652
+ If a spectrometer has bad_pixels configured in the EEPROM, then average
653
+ over them in the driver.
654
+ Note this function modifies the passed array in-place, rather than
655
+ returning a modified copy.
656
+ @note assumes bad_pixels is previously sorted
657
+ """
658
+
659
+ if self.settings is None or \
660
+ self.settings.eeprom is None or \
661
+ self.settings.eeprom.bad_pixels is None or \
662
+ len(self.settings.eeprom.bad_pixels) == 0 or \
663
+ self.settings.state.detector_regions is not None:
664
+ return False
665
+
666
+ if spectrum is None or len(spectrum) == 0:
667
+ return False
668
+
669
+ pixels = len(spectrum)
670
+ bad_pixels = self.settings.eeprom.bad_pixels
671
+
672
+ # iterate over each bad pixel
673
+ i = 0
674
+ while i < len(bad_pixels):
675
+
676
+ bad_pix = bad_pixels[i]
677
+
678
+ if bad_pix == 0:
679
+ # handle the left edge
680
+ next_good = bad_pix + 1
681
+ while next_good in bad_pixels and next_good < pixels:
682
+ next_good += 1
683
+ i += 1
684
+ if next_good < pixels:
685
+ for j in range(next_good):
686
+ spectrum[j] = spectrum[next_good]
687
+ else:
688
+
689
+ # find previous good pixel
690
+ prev_good = bad_pix - 1
691
+ while prev_good in bad_pixels and prev_good >= 0:
692
+ prev_good -= 1
693
+
694
+ if prev_good >= 0:
695
+ # find next good pixel
696
+ next_good = bad_pix + 1
697
+ while next_good in bad_pixels and next_good < pixels:
698
+ next_good += 1
699
+ i += 1
700
+
701
+ if next_good < pixels:
702
+ # for now, draw a line between previous and next_good pixels
703
+ # TODO: consider some kind of curve-fit
704
+ delta = float(spectrum[next_good] - spectrum[prev_good])
705
+ rng = next_good - prev_good
706
+ step = delta / rng
707
+ # log.debug(f"correct_bad_pixels: bad_pix {bad_pix}, prev_good {prev_good} ({spectrum[prev_good]}), next_good {next_good} ({spectrum[next_good]}), delta {delta:.2f}, rng {rng}, step {step:.2f}")
708
+ for j in range(rng - 1):
709
+ new = spectrum[prev_good] + step * (j + 1)
710
+ spectrum[prev_good + j + 1] = new
711
+ # log.debug(f"correct_bad_pixels: stomping pix {prev_good + j + 1} with {new}")
712
+ else:
713
+ # we ran off the high end, so copy-right
714
+ for j in range(bad_pix, pixels):
715
+ spectrum[j] = spectrum[prev_good]
716
+
717
+ # advance to next bad pixel
718
+ i += 1
719
+ return True
720
+
721
+ def _send_code(self,
722
+ bRequest: int,
723
+ wValue: int = 0,
724
+ wIndex: int = 0,
725
+ data_or_wLength = None,
726
+ label: str = "",
727
+ dry_run: bool = False,
728
+ retry_on_error: bool = False,
729
+ success_result: int = 0x00) -> SpectrometerResponse:
730
+ if self.shutdown_requested or (not self.connected and not self.connecting):
731
+ log.debug("_send_code: not attempting because not connected")
732
+ return SpectrometerResponse(False)
733
+
734
+ prefix = "" if not label else ("%s: " % label)
735
+ result = None
736
+
737
+ if data_or_wLength is None:
738
+ if self.settings.is_arm():
739
+ data_or_wLength = [0] * 8
740
+ else:
741
+ data_or_wLength = 0
742
+
743
+ log.debug("%s_send_code: request 0x%02x value 0x%04x index 0x%04x data/len %s",
744
+ prefix, bRequest, wValue, wIndex, data_or_wLength)
745
+
746
+ if dry_run:
747
+ return SpectrometerResponse(keep_alive=True)
748
+
749
+ if self._check_for_random_error():
750
+ return SpectrometerResponse(poison_pill=False)
751
+
752
+ retry_count = 0
753
+ while True:
754
+ try:
755
+ self._wait_for_usb_available()
756
+ result = self.device_type.ctrl_transfer(self.device,
757
+ 0x40, # HOST_TO_DEVICE
758
+ bRequest,
759
+ wValue,
760
+ wIndex,
761
+ data_or_wLength) # add TIMEOUT_MS parameter?
762
+ except Exception as exc:
763
+ log.critical("Hardware Failure FID Send Code Problem with ctrl transfer", exc_info=1)
764
+ self._schedule_disconnect(exc)
765
+ return SpectrometerResponse(poison_pill=True)
766
+
767
+ log.debug("%s_send_code: request 0x%02x value 0x%04x index 0x%04x data/len %s: result %s",
768
+ prefix, bRequest, wValue, wIndex, data_or_wLength, result)
769
+
770
+ if not retry_on_error:
771
+ return SpectrometerResponse(keep_alive=True)
772
+
773
+ # retry logic enabled, so compare result to expected
774
+ matched_expected = True
775
+ if len(success_result) < len(result):
776
+ matched_expected = False
777
+ else:
778
+ for i in range(len(success_result)):
779
+ if result[i] != success_result[i]:
780
+ matched_expected = False
781
+ break
782
+
783
+ if matched_expected:
784
+ return SpectrometerResponse(keep_alive=True)
785
+
786
+ # apparently it didn't match expected
787
+ retry_count += 1
788
+ if retry_count > self.retry_max:
789
+ log.error("giving up after %d retries", retry_count)
790
+ return SpectrometerResponse(poison_pill=True)
791
+
792
+ # try again
793
+ log.error("retrying (attempt %d)", retry_count + 1)
794
+ return SpectrometerResponse(True)
795
+
796
+ ## @note weird that so few calls to this function override the default wLength
797
+ # @todo consider adding retry logic as well
798
+ def _get_code(self,
799
+ bRequest: int,
800
+ wValue: int = 0,
801
+ wIndex: int = 0,
802
+ wLength: int = 64,
803
+ label: str = "",
804
+ msb_len: int = None,
805
+ lsb_len: int = None) -> SpectrometerResponse:
806
+ prefix = "" if not label else ("%s: " % label)
807
+ result = None
808
+
809
+ if self.shutdown_requested or (not self.connected and not self.connecting):
810
+ log.debug("_get_code: not attempting because not connected")
811
+ return SpectrometerResponse()
812
+
813
+ if self._check_for_random_error():
814
+ log.debug("random error")
815
+ return SpectrometerResponse(poison_pill=True)
816
+
817
+ try:
818
+ self._wait_for_usb_available()
819
+ result = self.device_type.ctrl_transfer(self.device,
820
+ 0xc0, # DEVICE_TO_HOST
821
+ bRequest,
822
+ wValue,
823
+ wIndex,
824
+ wLength)
825
+ except Exception as exc:
826
+ log.critical(f"Hardware Failure FID Get Code Problem with ctrl transfer (bRequest 0x{bRequest:02x}, wValue 0x{wValue:04x}, wIndex 0x{wIndex:04x}, label {label})", exc_info=1)
827
+ self._schedule_disconnect(exc)
828
+ return SpectrometerResponse(poison_pill=True)
829
+
830
+ result_hex = " ".join([f"{v:02x}" for v in result])
831
+ log.debug("%s_get_code: request 0x%02x value 0x%04x index 0x%04x = [%s]",
832
+ prefix, bRequest, wValue, wIndex, result_hex)
833
+
834
+ if result is None:
835
+ log.critical("_get_code[%s, %s]: received null", label, self.device_id)
836
+ self._schedule_disconnect(f"_get_code[{label}] received NULL")
837
+ return SpectrometerResponse(keep_alive=True)
838
+
839
+ # demarshall or return raw array
840
+ value = 0
841
+ if msb_len is not None:
842
+ for i in range(msb_len):
843
+ value = value << 8 | result[i]
844
+ return SpectrometerResponse(data=value)
845
+ elif lsb_len is not None:
846
+ for i in range(lsb_len):
847
+ if i < len(result):
848
+ value = (result[i] << (8 * i)) | value
849
+ return SpectrometerResponse(data=value)
850
+ else:
851
+ return SpectrometerResponse(data=result)
852
+
853
+ def get_upper_code(self,
854
+ wValue: int,
855
+ wIndex: int = 0,
856
+ wLength: int = 64,
857
+ label: str = "",
858
+ msb_len: int = None,
859
+ lsb_len: int = None) -> SpectrometerResponse:
860
+ return self._get_code(0xff, wValue, wIndex, wLength, label=label, msb_len=msb_len, lsb_len=lsb_len)
861
+
862
+ # ##########################################################################
863
+ # initialization
864
+ # ##########################################################################
865
+
866
+ def _read_eeprom(self):
867
+ buffers = []
868
+ for page in range(EEPROM.MAX_PAGES):
869
+ buf = None
870
+ try:
871
+ response = self.get_upper_code(0x01, page, label="GET_MODEL_CONFIG(%d)" % page)
872
+ buf = response.data
873
+ if response.error_lvl != ErrorLevel.ok:
874
+ return response
875
+ except:
876
+ log.error("exception reading upper_code 0x01 with page %d", page, exc_info=1)
877
+ if buf is None:
878
+ msg = "unable to read EEPROM (null buf)"
879
+ log.error(msg)
880
+ return SpectrometerResponse(False, error_lvl=ErrorLevel.medium, error_msg=msg)
881
+ elif len(buf) < 64:
882
+ msg = f"unable to read EEPROM received buf of {buf} and len {len(buf)}"
883
+ log.error(msg)
884
+ return SpectrometerResponse(False, error_lvl=ErrorLevel.medium, error_msg=msg)
885
+ buffers.append(buf)
886
+
887
+ flat_buffers_all_ones = True
888
+ for page in buffers:
889
+ for byte in page:
890
+ flat_buffers_all_ones = flat_buffers_all_ones and (byte == 0xFF)
891
+
892
+ if flat_buffers_all_ones:
893
+ return SpectrometerResponse(data=False, error_msg="Saw all Fs for EEPROM. Check EEPROM Programmed.", error_lvl=ErrorLevel.low)
894
+ return SpectrometerResponse(data=self.settings.eeprom.parse(buffers))
895
+
896
+ def has_linearity_coeffs(self):
897
+ """
898
+ At least one linearity coeff is other than 0 or -1 (and no NaN).
899
+
900
+ Public because used by wasatch-shell.
901
+ """
902
+ if self.settings.eeprom.linearity_coeffs:
903
+ for c in self.settings.eeprom.linearity_coeffs:
904
+ if math.isnan(c):
905
+ return False
906
+ for c in self.settings.eeprom.linearity_coeffs:
907
+ if c != 0 and c != -1:
908
+ return True
909
+ return False
910
+
911
+ def _read_fpga_compilation_options(self):
912
+ if self.settings.is_arm():
913
+ log.debug("ARM spectrometers no longer supporting FPGA compilation options")
914
+ return
915
+
916
+ response = self.get_upper_code(0x04, label="READ_COMPILATION_OPTIONS", lsb_len=3)
917
+ word = response.data
918
+ self.settings.fpga_options.parse(word)
919
+
920
+ # ##########################################################################
921
+ # Accessors
922
+ # ##########################################################################
923
+
924
+ # @todo test endian order (in and out)
925
+ def get_battery_register(self, reg: int):
926
+ reg = reg & 0xffff
927
+ return self.get_upper_code(0x14, wIndex=reg, label="GET_BATTERY_REG", msb_len=2)
928
+
929
+ def get_battery_state_raw(self):
930
+ """Retrieves the raw battery reading and then caches it for 1 sec"""
931
+ now = datetime.datetime.now()
932
+ if self.settings.state.battery_timestamp is not None and (now - self.settings.state.battery_timestamp).total_seconds() < 1:
933
+ return SpectrometerResponse(data=self.settings.state.battery_raw)
934
+
935
+ self.settings.state.battery_timestamp = now
936
+ response = self.get_upper_code(0x13, label="GET_BATTERY_STATE", msb_len=3)
937
+ self.settings.state.battery_raw = response.data
938
+
939
+ log.debug(f"battery_state_raw: 0x{self.settings.state.battery_raw:04x}")
940
+ return SpectrometerResponse(data=self.settings.state.battery_raw)
941
+
942
+ def get_battery_percentage(self):
943
+ response = self.get_battery_state_raw()
944
+ word = response.data
945
+ lsb = (word >> 16) & 0xff
946
+ msb = (word >> 8) & 0xff
947
+ perc = msb + (1.0 * lsb / 256.0)
948
+ log.debug("battery_perc: %.2f%%", perc)
949
+ return SpectrometerResponse(data=perc)
950
+
951
+ def get_battery_charging(self):
952
+ res = self.get_battery_state_raw()
953
+ word = res.data
954
+ charging = (0 != (word & 0xff))
955
+ return SpectrometerResponse(data=charging)
956
+
957
+ def get_integration_time_ms(self):
958
+ response = self._get_code(0xbf, label="GET_INTEGRATION_TIME_MS", lsb_len=3)
959
+ ms = response.data
960
+
961
+ if self.settings.state.integration_time_ms > 0:
962
+ log.debug(f"GET_INTEGRATION_TIME_MS: now {ms}")
963
+ self.settings.state.integration_time_ms = ms
964
+ else:
965
+ log.debug("declining to initialize session integration_time_ms from spectrometer")
966
+
967
+ return SpectrometerResponse(data=ms)
968
+
969
+ def set_dfu_enable(self):
970
+ """
971
+ Puts ARM-based spectrometers into Device Firmware Update (DFU) mode.
972
+ @warning reflashing spectrometer firmware without specific instruction and
973
+ support from Wasatch Photonics will void your warranty
974
+ """
975
+ if not self.settings.is_arm():
976
+ msg = "DFU mode only supported for ARM-based spectrometers"
977
+ log.error(msg)
978
+ return SpectrometerResponse(error_lvl=ErrorLevel.low, error_msg=msg, keep_alive=True)
979
+
980
+ result = self._send_code(0xfe, label="SET_DFU_ENABLE")
981
+
982
+ self.queue_message("marquee_info", "%s in DFU mode" % self.settings.eeprom.serial_number)
983
+
984
+ self._schedule_disconnect(Exception("DFU Mode"))
985
+ return SpectrometerResponse(data=result)
986
+
987
+ def set_detector_offset(self, value: int):
988
+ word = utils.clamp_to_int16(value)
989
+ self.settings.eeprom.detector_offset = word
990
+ # log.debug("value %d (%s) = 0x%04x (%s)", value, format(value, 'b'), word, format(word, 'b'))
991
+ return self._send_code(0xb6, word, label="SET_DETECTOR_OFFSET")
992
+
993
+ def set_detector_offset_odd(self, value: int):
994
+ if not self.settings.is_ingaas():
995
+ log.debug("SET_DETECTOR_OFFSET_ODD only supported on InGaAs")
996
+ return SpectrometerResponse(keep_alive=True, error_lvl=ErrorLevel.low)
997
+
998
+ word = utils.clamp_to_int16(value)
999
+ self.settings.eeprom.detector_offset_odd = word
1000
+
1001
+ return self._send_code(0x9c, word, label="SET_DETECTOR_OFFSET_ODD")
1002
+
1003
+ def get_detector_gain(self, update_session_eeprom: bool = False):
1004
+ """
1005
+ Read the device stored gain. Convert from binary "half-precision" float.
1006
+ - 1st byte (LSB) is binary encoded: bit 0 = 1/2, bit 1 = 1/4, bit 2 = 1/8 etc.
1007
+ - 2nd byte (MSB) is the integral part to the left of the decimal
1008
+ E.g., 231 dec == 0x01e7 == 1.90234375
1009
+ """
1010
+ res = self._get_code(0xc5, label="GET_DETECTOR_GAIN")
1011
+
1012
+ result = res.data
1013
+
1014
+ if result is None:
1015
+ msg = "GET_DETECTOR_GAIN returned NULL!"
1016
+ log.error(msg)
1017
+ return SpectrometerResponse(error_lvl=ErrorLevel.medium, error_msg=msg, keep_alive=True)
1018
+
1019
+ lsb = result[0] # little-endian, OPPOSITE of set_detector_gain
1020
+ msb = result[1]
1021
+
1022
+ raw = (msb << 8) | lsb
1023
+
1024
+ gain = msb + lsb / 256.0
1025
+ log.debug("get_detector_gain: %f (raw 0x%04x) (session eeprom %f)" % (
1026
+ gain, raw, self.settings.eeprom.detector_gain))
1027
+
1028
+ if update_session_eeprom:
1029
+ self.settings.eeprom.detector_gain = gain
1030
+
1031
+ if self.settings.is_micro():
1032
+ self.settings.state.gain_db = gain
1033
+
1034
+ return SpectrometerResponse(data=gain)
1035
+
1036
+ def get_detector_gain_odd(self, update_session_eeprom: bool = False):
1037
+ if not self.settings.is_ingaas():
1038
+ log.debug("GET_DETECTOR_GAIN_ODD only supported on InGaAs")
1039
+ return SpectrometerResponse(data=self.settings.eeprom.detector_gain_odd)
1040
+
1041
+ result = self._get_code(0x9f, label="GET_DETECTOR_GAIN_ODD")
1042
+
1043
+ # not reversing this for XS, because we don't currently have an XS-NIR
1044
+ lsb = result[0] # LSB-MSB
1045
+ msb = result[1]
1046
+ raw = (msb << 8) | lsb
1047
+
1048
+ gain = msb + lsb / 256.0
1049
+ log.debug("get_detector_gain_odd: %f (0x%04x) (session eeprom %f)" % (
1050
+ gain, raw, self.settings.eeprom.detector_gain_odd))
1051
+ if update_session_eeprom:
1052
+ self.settings.eeprom.detector_gain_odd = gain
1053
+ return SpectrometerResponse(data=gain)
1054
+
1055
+ ##
1056
+ # Note that this is used for detector types, including:
1057
+ #
1058
+ # - Hamamatsu silicon (S16010-*, S16011-*, etc)
1059
+ # - Hamamatsu InGaAs (G9214 etc)
1060
+ # - Sony IMX (IMX385 etc)
1061
+ #
1062
+ # It is important to understand that the UNIT of this value changes between
1063
+ # Hamamatsu and IMX detectors, but the DATATYPE does not.
1064
+ #
1065
+ # Reasonable gain levels for Hamamatsu are a floating-point scalar, literally
1066
+ # used to scale (gain) the signal, and are usually in the range (0.8 .. 1.2)
1067
+ # or thereabouts.
1068
+ #
1069
+ # Reasonable levels for IMX sensors are in dB and vary by detector, but are
1070
+ # usually in the range (0.0 .. 31.0), with exactly 0.1dB precision. The
1071
+ # spectrometer's FW will round to the nearest setting (1.23 will be rounded
1072
+ # to 1.2). IMX sensors switch from "analog gain" to "digital gain" above
1073
+ # a given threshold...on the IMX123, analog gain is 0.0 - 31.0, and digital
1074
+ # is 31.1 - 72.0 (I think).
1075
+ #
1076
+ # @see https://wasatchphotonics.com/api/Wasatch.NET/class_wasatch_n_e_t_1_1_funky_float.html
1077
+ # @todo we should probably track runtime hardware gain in SpectrometerState,
1078
+ # not EEPROM
1079
+ def set_detector_gain(self, gain: float):
1080
+ if not self.is_sensor_stable():
1081
+ log.error("declining to change sensor settings while stabilizing")
1082
+ return
1083
+
1084
+ raw = self.settings.eeprom.float_to_uint16(gain)
1085
+
1086
+ # MZ: note that we SEND gain MSB-LSB, but we READ gain LSB-MSB?!
1087
+ log.debug("Send Detector Gain: 0x%04x (%s)", raw, gain)
1088
+ result = self._send_code(0xb7, raw, label="SET_DETECTOR_GAIN")
1089
+
1090
+ self.require_throwaway(gain != self.settings.eeprom.detector_gain)
1091
+ self.settings.eeprom.detector_gain = gain
1092
+
1093
+ if self.settings.is_xs():
1094
+ # self.queue_message("marquee_info", "sensor is stabilizing (gain)")
1095
+ self.settings.state.gain_db = gain
1096
+
1097
+ return result
1098
+
1099
+ def set_detector_gain_odd(self, gain: float):
1100
+ if not self.settings.is_ingaas():
1101
+ log.debug("SET_DETECTOR_GAIN_ODD only supported on InGaAs")
1102
+ return SpectrometerResponse(error_lvl=ErrorLevel.low, error_msg="SET_DETECTOR_GAIN_ODD only supported on InGaAs")
1103
+
1104
+ raw = self.settings.eeprom.float_to_uint16(gain)
1105
+
1106
+ # MZ: note that we SEND gain MSB-LSB, but we READ gain LSB-MSB?!
1107
+ log.debug("Send Detector Gain Odd: 0x%04x (%s)", raw, gain)
1108
+ self.settings.eeprom.detector_gain_odd = gain
1109
+ return self._send_code(0x9d, raw, label="SET_DETECTOR_GAIN_ODD")
1110
+
1111
+ def set_area_scan_enable(self, flag):
1112
+ """
1113
+ Historically, this opcode moved around a bit. It is currently 0xeb, which
1114
+ conflicts with CF_SELECT). At one point it was 0xe9, which conflicted with
1115
+ LASER_RAMP_ENABLE.
1116
+ """
1117
+ if self.settings.is_ingaas():
1118
+ log.error("area scan is not supported on InGaAs detectors (single line array)")
1119
+ return SpectrometerResponse(error_lvl=ErrorLevel.low, error_msg="area scan is not supported on InGaAs detectors (single line array)")
1120
+
1121
+ value = 1 if flag else 0
1122
+ self.settings.state.area_scan_enabled = flag
1123
+ self.settings.state.area_scan_first_trigger_sent = False
1124
+ return self._send_code(0xeb, value, label="SET_AREA_SCAN_ENABLE")
1125
+
1126
+ def set_area_scan_line_step(self, n):
1127
+ if not self.settings.is_xs():
1128
+ msg = "area scan line step is only supported on XS"
1129
+ log.error(msg)
1130
+ return SpectrometerResponse(error_lvl=ErrorLevel.low, error_msg=msg)
1131
+
1132
+ n = int(max(1, min(n, 255)))
1133
+
1134
+ log.debug(f"setting area scan line step to {n}")
1135
+ self.settings.state.area_scan_line_step = n
1136
+ return self.i2c_write(0x17, [n], label="SET_AREA_SCAN_LINE_STEP")
1137
+
1138
+ # MZ: I don't remember what boards / firmware supports this?
1139
+ def set_area_scan_line_count(self, n):
1140
+ self.debug(f"setting line count to {n}")
1141
+ self.dev.ctrl_transfer(H2D, 0xa6, n, 0, Z, TIMEOUT)
1142
+ return self._send_code(0xa6, n, label="SET_AREA_SCAN_LINE_COUNT")
1143
+
1144
+ # MZ: I don't remember what boards / firmware supports this?
1145
+ def set_area_scan_line_interval(self, n):
1146
+ self.debug(f"setting line interval to {n}")
1147
+ return self._send_code(0xa8, n, label="SET_AREA_SCAN_INTERVAL")
1148
+
1149
+ def get_sensor_line_length(self):
1150
+ value = self.get_upper_code(0x03, label="GET_LINE_LENGTH", lsb_len=2)
1151
+ if value != self.settings.eeprom.actual_pixels_horizontal:
1152
+ log.error("GET_LINE_LENGTH opcode result %d != EEPROM.actual_pixels_horizontal %d (using opcode result)",
1153
+ value, self.settings.eeprom.actual_pixels_horizontal)
1154
+ return SpectrometerResponse(data=value)
1155
+
1156
+ def get_microcontroller_firmware_version(self):
1157
+ if self.settings.microcontroller_firmware_version is not None:
1158
+ return SpectrometerResponse(data=self.settings.microcontroller_firmware_version)
1159
+
1160
+ res = self._get_code(0xc0, label="GET_CODE_REVISION")
1161
+ result = res.data
1162
+ version = "?.?.?.?"
1163
+ if result is not None and len(result) >= 4:
1164
+ version = "%d.%d.%d.%d" % (result[3], result[2], result[1], result[0]) # MSB-LSB
1165
+
1166
+ self.settings.microcontroller_firmware_version = version
1167
+ return SpectrometerResponse(data=version)
1168
+
1169
+ def get_fpga_firmware_version(self):
1170
+ if self.settings.fpga_firmware_version is not None:
1171
+ return SpectrometerResponse(data=self.settings.fpga_firmware_version)
1172
+
1173
+ s = ""
1174
+ res = self._get_code(0xb4, wLength=7, label="GET_FPGA_REV")
1175
+ result = res.data
1176
+ if result is not None:
1177
+ for i in range(len(result)):
1178
+ c = result[i]
1179
+ if 0x20 <= c < 0x7f: # visible ASCII
1180
+ s += chr(c)
1181
+
1182
+ self.settings.fpga_firmware_version = s
1183
+ return SpectrometerResponse(data=s)
1184
+
1185
+ def get_microcontroller_serial_number(self):
1186
+ if not self.settings.is_arm():
1187
+ log.debug("GET_MICROCONTROLLER_SERIAL_NUMBER requires ARM")
1188
+ return None
1189
+
1190
+ if not self.settings.supports_feature("microcontroller_serial_number"):
1191
+ log.debug("GET_MICROCONTROLLER_SERIAL_NUMBER not supported on this firmware")
1192
+ return None
1193
+
1194
+ result = self._get_code(0xff, wValue=0x2c, wLength=12, label="GET_MICROCONTROLLER_SERIAL_NUMBER")
1195
+ if result is None:
1196
+ log.error("GET_MICROCONTROLLER_SERIAL_NUMBER returned None")
1197
+ return None
1198
+
1199
+ data = result.data
1200
+ if data is None or len(data) != 12:
1201
+ log.error("GET_MICROCONTROLLER_SERIAL_NUMBER expected 12 bytes (received {len(data)})")
1202
+ return None
1203
+
1204
+ des = "".join(f"{v:02x}" for v in data) # Device Electronic Signature
1205
+ self.settings.microcontroller_serial_number = des
1206
+ return SpectrometerResponse(data=des)
1207
+
1208
+ def get_ble_firmware_version(self):
1209
+ if not self.settings.is_arm():
1210
+ log.debug("GET_BLE_FIRMWARE_VERSION requires ARM")
1211
+ return None
1212
+
1213
+ if not self.settings.supports_feature("get_ble_firmware_version"):
1214
+ log.debug("GET_BLE_FIRMWARE_VERSION not supported on this firmware")
1215
+ return None
1216
+
1217
+ result = self._get_code(0xff, wValue=0x2d, wLength=32, label="GET_BLE_FIRMWARE_VERSION")
1218
+ if result is None:
1219
+ return None
1220
+
1221
+ data = result.data
1222
+ if data is None:
1223
+ return None
1224
+
1225
+ s = ""
1226
+ for c in data:
1227
+ if c == 0:
1228
+ break
1229
+ s += chr(c)
1230
+ self.settings.ble_firmware_version = s
1231
+ return SpectrometerResponse(data=s)
1232
+
1233
+ def apply_edc(self, spectrum):
1234
+ """
1235
+ Use optically black pixels for electrical dark correction (EDC).
1236
+
1237
+ @par IMX385LQR-C datasheet (p8)
1238
+
1239
+ @verbatim
1240
+ Pixels Count Description
1241
+ 0-3 4 OB side ignored area <-- using this
1242
+ 4-7 4 Effective pixel side ignored area
1243
+ 8-15 8 Effective margin for color processing
1244
+ 16-1935 1920 Recording pixel area
1245
+ 1936-1944 9 Effective margin for color processing
1246
+ 1945-1948 4 Effective pixel side ignored area
1247
+ 1949-1951 3 Dummy
1248
+ @endverbatim
1249
+
1250
+ @note that this is called BEFORE horizontal binning, so should still work
1251
+ even with BIN_4X2
1252
+
1253
+ @todo we might want to make buffer length configurable, either in spectra
1254
+ or by time (consider 10ms vs 1sec integration time)
1255
+ """
1256
+ if not self.settings.state.edc_enabled:
1257
+ return spectrum
1258
+
1259
+ if not self.settings.is_imx():
1260
+ return spectrum
1261
+
1262
+ if len(spectrum) != 1952:
1263
+ log.error("IMX EDC hard-coded to expect 1952px")
1264
+ return spectrum
1265
+
1266
+ # this averages electrical dark over SPACE
1267
+ dark_pixels = spectrum[0:4]
1268
+ avg_dark = round(sum(dark_pixels) / len(dark_pixels), 2)
1269
+
1270
+ return [ v - avg_dark for v in spectrum ]
1271
+
1272
+ def get_line(self, trigger=True, auto_raman_params=None):
1273
+ """ legacy alias """
1274
+ return self.get_spectrum(trigger, auto_raman_params)
1275
+
1276
+ def generate_timeout_ms(self):
1277
+ max_integ_ms = max(self.settings.state.integration_time_ms, self.settings.state.prev_integration_time_ms)
1278
+ if self.settings.is_micro():
1279
+ # we have no idea if Series-XS has to "wake up" the sensor, so wait
1280
+ # long enough for 20ms + 8 throwaway frames if need be (IMX385 datasheet p69)
1281
+ if self.settings.state.onboard_averaging:
1282
+ timeout_ms = max_integ_ms * (self.settings.state.scans_to_average + 7) + 500 * self.settings.num_connected_devices + 20
1283
+ else:
1284
+ timeout_ms = max_integ_ms * 8 + 500 * self.settings.num_connected_devices + 20
1285
+ if not self.has_received_spectrum:
1286
+ timeout_ms += 10_000
1287
+ else:
1288
+ timeout_ms = max_integ_ms * 2 + 1_000 * self.settings.num_connected_devices
1289
+ return int(timeout_ms)
1290
+
1291
+ def get_spectrum(self, trigger=True, auto_raman_params=None):
1292
+ """
1293
+ Send "acquire", then immediately read the bulk endpoint(s).
1294
+ Probably the most important method in this class.
1295
+
1296
+ @param trigger (Input) send an initial ACQUIRE
1297
+ @param auto_raman_params (Input) if present, use VR_ACQUIRE_AUTO_RAMAN
1298
+ rather than the usual VR_ACQUIRE_CCD
1299
+ @returns tuple of (spectrum[], area_scan_row_count) for success
1300
+ @returns None when it times-out while waiting for an external trigger
1301
+ (interpret as, "didn't find any fish this time, try again in a bit")
1302
+ @returns False (bool) when it times-out or encounters an exception
1303
+ when NOT in external-triggered mode
1304
+ @throws exception on timeout (unless external triggering enabled)
1305
+ """
1306
+ response = SpectrometerResponse()
1307
+
1308
+ if not self.is_sensor_stable():
1309
+ response.error_msg = "get_spectrum: leaving sensor alone while stabilizing"
1310
+ response.error_lvl = ErrorLevel.low
1311
+ response.keep_alive = True
1312
+ log.debug(response.error_msg)
1313
+ self.queue_message("marquee_info", "sensor is stabilizing")
1314
+ return response
1315
+
1316
+ ########################################################################
1317
+ # send the ACQUIRE
1318
+ ########################################################################
1319
+
1320
+ # main use-case for NOT sending a trigger would be when reading
1321
+ # subsequent lines of data from area scan "fast" mode
1322
+
1323
+ acquisition_timestamp = datetime.datetime.now()
1324
+
1325
+ # should we send a trigger?
1326
+ if self.settings.state.trigger_source != SpectrometerState.TRIGGER_SOURCE_INTERNAL:
1327
+ trigger = False
1328
+ if self.settings.state.area_scan_enabled:
1329
+ if self.settings.state.area_scan_first_trigger_sent:
1330
+ log.debug("get_spectrum: sending FIRST trigger after enabling area scan")
1331
+ self.settings.state.area_scan_first_trigger_sent = True
1332
+ else:
1333
+ log.debug("get_spectrum: skipping trigger during area scan")
1334
+ trigger = False
1335
+
1336
+ if trigger:
1337
+ # send an internal SW trigger
1338
+ log.debug("get_spectrum: requesting spectrum")
1339
+
1340
+ # should that trigger be an ACQUIRE or ACQUIRE_AUTO_RAMAN?
1341
+ if auto_raman_params:
1342
+ self._send_code(0xfd, data_or_wLength=auto_raman_params, label="ACQUIRE_AUTO_RAMAN")
1343
+ else:
1344
+ self._send_code(0xad, label="ACQUIRE_SPECTRUM")
1345
+
1346
+ ########################################################################
1347
+ # prepare to read spectrum
1348
+ ########################################################################
1349
+
1350
+ # this is the number of pixels we expect to read-out over USB
1351
+ if self.settings.is_imx385():
1352
+ # Currently, only the IMX385 uses horizontal binning, so for now
1353
+ # limit code changes to that detector (even though they haven't been
1354
+ # shown to break other hardware)
1355
+ pixels = self.settings.eeprom.actual_pixels_horizontal
1356
+ else:
1357
+ pixels = self.settings.pixels()
1358
+
1359
+ # when changing detector ROI, exactly ONE READ should be at the previous length
1360
+ # if self.prev_pixels is not None:
1361
+ # log.debug(f"get_spectrum: using one-time prev_pixels value of {self.prev_pixels} rather than {pixels}")
1362
+ # pixels = self.prev_pixels
1363
+ # self.prev_pixels = None
1364
+
1365
+ # all models return spectra as [uint16]
1366
+ endpoints = [0x82]
1367
+ block_len_bytes = pixels * 2
1368
+ if pixels == 2048 and not self.settings.is_arm():
1369
+ endpoints = [0x82, 0x86]
1370
+ block_len_bytes = 2048 # 1024 pixels apiece from two endpoints
1371
+
1372
+ timeout_ms = self.generate_timeout_ms()
1373
+
1374
+ self._wait_for_usb_available()
1375
+
1376
+ ########################################################################
1377
+ # read the data from bulk endpoint(s)
1378
+ ########################################################################
1379
+
1380
+ spectrum = []
1381
+ errors = 0
1382
+ for endpoint in endpoints:
1383
+ data = None
1384
+ while data is None:
1385
+ try:
1386
+ log.debug("waiting for %d bytes (timeout %s ms)", block_len_bytes, timeout_ms)
1387
+ data = self.device_type.read(self.device, endpoint, block_len_bytes, timeout=timeout_ms)
1388
+ log.debug("read %d bytes", len(data))
1389
+ except Exception as exc:
1390
+ if self.device_type is None:
1391
+ log.error(f"No device_type")
1392
+ response.error_msg = f"Encountered error on read"
1393
+ response.error_lvl = ErrorLevel.high
1394
+ response.poison_pill = True # unrecoverable
1395
+ return response
1396
+ elif self.settings.state.trigger_source == SpectrometerState.TRIGGER_SOURCE_EXTERNAL or auto_raman_params:
1397
+ # we don't know how long we'll have to wait for the response, so
1398
+ # just loop and hope
1399
+ # log.debug("still waiting for spectrum")
1400
+ pass
1401
+ else:
1402
+ errors += 1
1403
+ log.error(f"Encountered error on read of {exc}", exc_info=1)
1404
+ if errors < 3:
1405
+ log.error(f"ignoring error number {errors}")
1406
+ else:
1407
+ response.error_msg = "Encountered error on read"
1408
+ response.error_lvl = ErrorLevel.high
1409
+ return response
1410
+
1411
+ # This is a convoluted way to iterate across the received bytes in 'data' as
1412
+ # two interleaved arrays, both only processing alternating bytes, but one (i)
1413
+ # starting at zero (even bytes) and the other (j) starting at 1 (odd bytes).
1414
+ subspectrum = [int(i | (j << 8)) for i, j in zip(data[::2], data[1::2])] # LSB-MSB
1415
+
1416
+ spectrum.extend(subspectrum)
1417
+
1418
+ # empirically determined need for 5ms delay when switching endpoints
1419
+ # on 2048px detectors during area scan
1420
+ if self.settings.state.area_scan_enabled and pixels == 2048: # and endpoint == 0x82:
1421
+ log.debug("sleeping 5ms between endpoints")
1422
+ sleep(0.005)
1423
+
1424
+ # received a response, so decrement throwaways
1425
+ self.remaining_throwaways = max(0, self.remaining_throwaways - 1)
1426
+
1427
+ ########################################################################
1428
+ # error-check the received spectrum
1429
+ ########################################################################
1430
+
1431
+ log.debug("get_spectrum: completed in %d ms (vs integration time %d ms)",
1432
+ round((datetime.datetime.now() - acquisition_timestamp).total_seconds() * 1000, 0),
1433
+ self.settings.state.integration_time_ms)
1434
+
1435
+ log.debug("get_spectrum: pixels %d, endpoints %s, block %d, spectrum %s ...",
1436
+ len(spectrum), endpoints, block_len_bytes, spectrum[0:9])
1437
+
1438
+ if len(spectrum) != pixels:
1439
+ log.error("get_spectrum read wrong number of pixels (expected %d, read %d)", pixels, len(spectrum))
1440
+ response.error_msg = f"get_spectrum read wrong number of pixels (expected {pixels}, read {len(spectrum)})"
1441
+ response.error_lvl = ErrorLevel.low
1442
+ response.keep_alive = True
1443
+ return response
1444
+
1445
+ ########################################################################
1446
+ #
1447
+ # post-process the spectrum
1448
+ #
1449
+ ########################################################################
1450
+
1451
+ ########################################################################
1452
+ # Success, so update "working" integration time
1453
+ ########################################################################
1454
+
1455
+ self.settings.state.prev_integration_time_ms = self.settings.state.integration_time_ms
1456
+ self.has_received_spectrum = True
1457
+
1458
+ ########################################################################
1459
+ # Apply InGaAs even/odd gain/offset in software
1460
+ ########################################################################
1461
+
1462
+ # (before x-axis inversion because this is where FPGA will do it)
1463
+
1464
+ # this should be done in the FPGA, but older FW didn't have that
1465
+ # implemented, so fix in SW unless EEPROM indicates "already handled"
1466
+ if self.settings.is_ingaas():
1467
+ if not self.settings.eeprom.hardware_even_odd:
1468
+ self._correct_ingaas_gain_and_offset(spectrum)
1469
+
1470
+ ########################################################################
1471
+ # Area Scan (rare)
1472
+ ########################################################################
1473
+
1474
+ # (before x-axis inversion because line index at FPGA pixel 0)
1475
+
1476
+ # If we're in area scan mode, use SECOND pixel as row index (leave pixel
1477
+ # in spectrum). Do this before any horizontal averaging which might
1478
+ # corrupt first pixel).
1479
+ area_scan_row_count = -1
1480
+ if self.settings.state.area_scan_enabled:
1481
+ if spectrum[0] == 0xffff:
1482
+ area_scan_row_count = spectrum[1]
1483
+ else:
1484
+ log.error("first pixel of area scan expected to be 0xffff, read 0x{spectrum[0]:04x}")
1485
+
1486
+ ########################################################################
1487
+ # Stomp array ends
1488
+ ########################################################################
1489
+
1490
+ # (before x-axis inversion because optically masked pixels are physical)
1491
+
1492
+ # some detectors have "garbage" pixels at the front or end of every
1493
+ # spectrum (sync bytes and what-not)
1494
+ if not self.settings.state.area_scan_enabled:
1495
+ if self.settings.is_imx385() and self.settings.fpga_firmware_version == "01.1.01":
1496
+ utils.stomp_first(spectrum, 3)
1497
+ utils.stomp_last (spectrum, 2)
1498
+ if self.settings.is_imx392() and self.settings.state.detector_regions is None:
1499
+ utils.stomp_first(spectrum, 3)
1500
+ utils.stomp_last (spectrum, 17)
1501
+
1502
+ ########################################################################
1503
+ # Electrical Dark Correction (EDC, experimental)
1504
+ ########################################################################
1505
+
1506
+ if not self.settings.state.area_scan_enabled:
1507
+ spectrum = self.apply_edc(spectrum)
1508
+
1509
+ ########################################################################
1510
+ # Invert X-Axis
1511
+ ########################################################################
1512
+
1513
+ # For benches where the detector is essentially rotated 180-deg from our
1514
+ # typical orientation with regard to the grating (e.g. InGaAs OEM
1515
+ # benches, where red wavelengths are diffracted toward pixel 0, and blue
1516
+ # wavelengths toward pixel 511).
1517
+ #
1518
+ # Note this simply performs a horizontal FLIP (mirror) of the vertically-
1519
+ # binned 1-D spectra, and is NOT sufficient to perform a genuine 180-
1520
+ # degree rotation of 2-D imaging mode; if "authentic" area scan is
1521
+ # desired, the caller would likewise need to reverse the display order of
1522
+ # the rows.
1523
+ #
1524
+ # It is VITALLY IMPORTANT that all drivers agree
1525
+ # on when to perform X-Axis inversion in the
1526
+ # Order of Operations.
1527
+ #
1528
+ if not self.settings.state.area_scan_enabled:
1529
+ if self.settings.eeprom.invert_x_axis:
1530
+ spectrum.reverse()
1531
+
1532
+ ########################################################################
1533
+ # ignore isolated "flat" spectra on SiG
1534
+ ########################################################################
1535
+
1536
+ if not self.settings.state.area_scan_enabled:
1537
+ if self.settings.is_micro() and utils.all_same(spectrum):
1538
+ response.error_msg = "skipping flat spectrum"
1539
+ response.error_lvl = ErrorLevel.low
1540
+ response.keep_alive = True
1541
+ log.debug(response.error_msg)
1542
+ self.queue_message("marquee_info", "sensor is stabilizing")
1543
+ return response
1544
+
1545
+ ########################################################################
1546
+ # Bad Pixel Correction
1547
+ ########################################################################
1548
+
1549
+ # Note these are pre-horizontal binning...
1550
+ if not self.settings.state.area_scan_enabled:
1551
+ if self.settings.state.bad_pixel_mode == SpectrometerState.BAD_PIXEL_MODE_AVERAGE:
1552
+ self._correct_bad_pixels(spectrum)
1553
+
1554
+ ########################################################################
1555
+ # Linear Pixel Calibration (experimental)
1556
+ ########################################################################
1557
+
1558
+ # should be done AFTER detector inversion (because that's how calibration
1559
+ # is generated) and AFTER bad pixel correction
1560
+
1561
+ if not self.settings.state.area_scan_enabled:
1562
+ if self.settings.linear_pixel_calibration:
1563
+ spectrum = self._apply_linear_pixel_calibration(spectrum)
1564
+
1565
+ ########################################################################
1566
+ # horizontal binning
1567
+ ########################################################################
1568
+
1569
+ spectrum = self._apply_horizontal_binning(spectrum)
1570
+
1571
+ # * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
1572
+ # Note: len(spectrum) may no longer == eeprom.actual_pixels_horizontal!
1573
+ # * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
1574
+
1575
+ ########################################################################
1576
+ # Graph Alternating Pixels
1577
+ ########################################################################
1578
+
1579
+ # When integrating new sensors, or testing "interleaved" detectors like
1580
+ # the InGaAs, sometimes we want to only look at "every other" pixel to
1581
+ # flatten-out irregularities in Bayer filters or photodiode arrays.
1582
+ # However, we don't want to disrupt the expected pixel-count, so just
1583
+ # average-over the skipped pixels.
1584
+ #
1585
+ # Not important enough to update for DetectorRegions
1586
+ if self.settings.state.graph_alternating_pixels:
1587
+ log.debug("applying graph_alternating_pixels")
1588
+ smoothed = []
1589
+ for i in range(len(spectrum)):
1590
+ if i % 2 == 0:
1591
+ smoothed.append(spectrum[i])
1592
+ else:
1593
+ if i + 1 < len(spectrum):
1594
+ averaged = (spectrum[i-1] + spectrum[i+1]) / 2.0
1595
+ else:
1596
+ averaged = spectrum[i - 1]
1597
+ smoothed.append(averaged)
1598
+ spectrum = smoothed
1599
+
1600
+ # Somewhat oddly, we're currently returning a TUPLE of the spectrum and
1601
+ # the area scan row count.
1602
+ response.data = SpectrumAndRow(spectrum, area_scan_row_count)
1603
+ return response
1604
+
1605
+ def require_throwaway(self, flag):
1606
+ if flag and self.settings.is_xs() and self.remaining_throwaways < 1:
1607
+ log.debug("queuing throwaways")
1608
+ self.remaining_throwaways += 1
1609
+
1610
+ def set_onboard_scans_to_average(self, n):
1611
+ if not self.settings.is_xs():
1612
+ return SpectrometerResponse(False)
1613
+
1614
+ retval = self._send_code(0xff, 0x62, n, label="SET_ONBOARD_SCANS_TO_AVERAGE")
1615
+ self.settings.state.scans_to_average = n
1616
+ self.settings.state.onboard_averaging = True
1617
+ return retval
1618
+
1619
+ def get_scans_to_average(self):
1620
+ return self._get_upper_code(0x63, lsb_len=2)
1621
+
1622
+ def set_integration_time_ms(self, ms: float):
1623
+ """
1624
+ Send the updated integration time in a control message to the device
1625
+ @warning disabled EEPROM range-checking by customer
1626
+ request; range limits in EEPROM are defined as 16-bit
1627
+ values, while integration time is actually a 24-bit value,
1628
+ such that the EEPROM is artificially limiting our range.
1629
+
1630
+ @todo SiG needs to wait 20ms + 8 frames for stabilization.
1631
+ """
1632
+ if not self.is_sensor_stable():
1633
+ log.error("declining to change sensor settings while stabilizing")
1634
+ return
1635
+
1636
+ ms = max(1, int(round(ms)))
1637
+
1638
+ lsw = ms & 0xffff
1639
+ msw = (ms >> 16) & 0x00ff
1640
+
1641
+ result = self._send_code(0xB2, lsw, msw, label="SET_INTEGRATION_TIME_MS")
1642
+ log.debug("SET_INTEGRATION_TIME_MS: now %d", ms)
1643
+
1644
+ self.require_throwaway(ms != self.settings.state.integration_time_ms)
1645
+ self.settings.state.prev_integration_time_ms = self.settings.state.integration_time_ms
1646
+ self.settings.state.integration_time_ms = ms
1647
+
1648
+ if self.settings.is_xs():
1649
+ # self.queue_message("marquee_info", "sensor is stabilizing (int time)")
1650
+ pass
1651
+
1652
+ return result
1653
+
1654
+ def is_sensor_stable(self):
1655
+ if not (self.settings.is_imx() and self.settings.supports_feature("imx_stabilization")):
1656
+ return True
1657
+
1658
+ self.get_poll_status()
1659
+ stable = PollStatus.IDLE == self.settings.state.poll_status
1660
+ # log.debug(f"is_sensor_stable: sensor {'IS' if stable else 'IS NOT'} stable (poll_status 0x{self.settings.state.poll_status:02x})")
1661
+ return stable
1662
+
1663
+ def get_poll_status(self):
1664
+ status = PollStatus.IDLE # .UNDEFINED (kludge)
1665
+ result = SpectrometerResponse(data=status)
1666
+
1667
+ if False and self.settings.is_imx() and self.settings.supports_feature("imx_stabilization"):
1668
+ result = self._get_code(0xd4, lsb_len=1, label="GET_POLL_STATUS")
1669
+ if result is not None:
1670
+ status = result.data
1671
+ self.settings.state.poll_status = status
1672
+ # log.debug(f"get_poll_status: status 0x{status:02x}")
1673
+ return result
1674
+
1675
+ def i2c_write(self, address, buf, label=""):
1676
+ return self._send_code(0x90, wValue=address, wIndex=len(buf), data_or_wLength=buf, label=label)
1677
+
1678
+ # ##########################################################################
1679
+ # Area Scan
1680
+ # ##########################################################################
1681
+
1682
+ def get_area_scan(self):
1683
+ """
1684
+ @returns Reading(spectrum, AreaScanImage(data=nd_array))
1685
+
1686
+ It's important to understand that Hamamatsu and XS area scan features
1687
+ are architected differently in several respects.
1688
+
1689
+ @par Hamamatsu Area Scan
1690
+
1691
+ Full-frame Hamamatsu area scans are collected and returned in a single
1692
+ blocking call. This is possible because:
1693
+
1694
+ - the frame is only 69 lines (6% of a 1080p image)
1695
+ - FX2-based 110008 boards use 480 mbps High-Speed USB (40x faster than
1696
+ 12 mbps Full-Speed)
1697
+ - CCDs are more sensitive as CMOS, allowing shorter integration times
1698
+ - Hamamatsu sensors don't have the 32ms min per-frame LVDS transfer time
1699
+ - Hamamatsu sensors allow vertical shifting and horizontal-readout to be
1700
+ delayed by the FPGA until the previous line has been pushed to the FX2,
1701
+ allowing every line in a frame to be sequentially read and pushed (vs
1702
+ IMX385 free-running frame times forcing one line/frame)
1703
+ - the FX2 internally generates subsequent ACQUIRE signals to the FPGA
1704
+ after each line has been read-out from the USB buffers to host, reducing
1705
+ the number of EP0 control messages
1706
+
1707
+ Due to the above, the Hamamatsu area scan can be executed much faster
1708
+ than an IMX385 area scan, and is considered fast enough to "block" on
1709
+ the full frame.
1710
+
1711
+ Therefore, a single blocking call to get_area_scan_hamamatsu() will
1712
+ return a complete area scan frame in roughly the following time:
1713
+
1714
+ INTEG_MS + TICK_US x LINES x (PIXELS + 1)
1715
+
1716
+ where:
1717
+ INTEG_MS is current integration time
1718
+ LINES is 69 for most Hamamatsu CCDs (128 for C0)
1719
+ PIXELS is 1044 or 2068 as appropriate (physical, not active)
1720
+ TICK_US is 2µs or 4µs as appropriate (depends on clock speed at
1721
+ which FPGA 'ticks' the CCD)
1722
+
1723
+ "PIXELS + 1" represents the 1 tick required for each line's vertical
1724
+ shift, plus PIXELS ticks for the horizontal read-out.
1725
+
1726
+ @par XS Area Scan
1727
+
1728
+ In contrast, as long as our IMX385 sensor is controlled in "main mode,"
1729
+ via Spartan6 with no additional DDR memory, over LVDS, the XS image scan
1730
+ must be read line-by-line, where each line requires a separate full-frame
1731
+ acquisition (32ms minimum latency).
1732
+
1733
+ Therefore, a full area scan of a 1080p detector would take AT LEAST 32ms
1734
+ x 1080 lines = 35sec just for LVDS readout to the FPGA. The 1080 ACQUIRE
1735
+ commands would stretch that out further. Over 12mbps Full-Speed USB, the
1736
+ entire process comes to something like 2.5 MINUTES operationally.
1737
+
1738
+ Therefore, a blocking call to get_area_scan_xs() will return ASINGLE LINE
1739
+ of an ONGOING area scan. This allows ENLIGHTEN to appear "responsive"
1740
+ during the area scan process, updating its graphical area scan image in
1741
+ real-time as each line is received.
1742
+
1743
+ To further speed the process, the XS FPGA will only return lines within
1744
+ the configured vertical ROI, so if a vertical ROI of (300, 500) is config-
1745
+ ured, the area scan image will only comprise 200 lines rather than the
1746
+ full 1080.
1747
+
1748
+ Also, since each call to this function will only return a single line,
1749
+ and the FPGA will automatically "roll-over" the current line index after
1750
+ coming to the configured stop_line (returning to the configured start_line),
1751
+ this function does not have a concept of "stopping" or "finishing" a frame;
1752
+ subsequent calls will just keep sending out additional lines, until the
1753
+ spectrometer is taken out of area scan mode.
1754
+
1755
+ """
1756
+ if self.settings.is_ingaas():
1757
+ return None
1758
+ elif self.settings.is_xs():
1759
+ return self.get_area_scan_xs()
1760
+ else:
1761
+ return self.get_area_scan_hamamatsu()
1762
+
1763
+ def get_area_scan_xs(self):
1764
+ """
1765
+ @returns Reading(spectrum, AreaScanImage(data=nd_array))
1766
+ """
1767
+ start = self.settings.eeprom.roi_vertical_region_1_start
1768
+ stop = self.settings.eeprom.roi_vertical_region_1_end
1769
+ line_len = self.settings.pixels() * 2
1770
+ timeout_ms = self.generate_timeout_ms()
1771
+
1772
+ # read a line (might be the "next" line, might be the "first" or "last"
1773
+ # line, might have skipped a few, who knows)
1774
+ self._send_code(0xad, label="ACQUIRE_SPECTRUM")
1775
+
1776
+ # start with any extra data we might have picked up on the last read
1777
+ data = self.extra_area_scan_data
1778
+ self.extra_area_scan_data = []
1779
+
1780
+ try:
1781
+ while len(data) < line_len:
1782
+ bytes_remaining = line_len - len(data)
1783
+ latest_data = self.device_type.read(self.device, 0x82, bytes_remaining, timeout=timeout_ms)
1784
+ data.extend(latest_data)
1785
+ except:
1786
+ log.error(f"get_area_scan_xs: error reading line {line}", exc_info=1)
1787
+ return None
1788
+
1789
+ extra_len = len(data) - line_len
1790
+ if extra_len > 0:
1791
+ log.warn(f"get_area_scan_xs: storing {extra_len} extra bytes toward the next line")
1792
+ self.extra_area_scan_data = line_data[line_len:]
1793
+
1794
+ # demarshal line into spectrum
1795
+ spectrum = []
1796
+ for i in range(self.settings.pixels()):
1797
+ lsb = data[i*2 + 0]
1798
+ msb = data[i*2 + 1]
1799
+ intensity = (msb << 8) + lsb
1800
+ spectrum.append(intensity)
1801
+ spectrum = np.array(spectrum, dtype=np.uint16)
1802
+
1803
+ # first pixel is line index
1804
+ line_index = spectrum[0]
1805
+ if self.settings.supports_feature("xs_area_scan_offset_kludge"):
1806
+ line_index -= 40
1807
+
1808
+ # for some reason, line index is copied across first four pixels?
1809
+ for i in range(4):
1810
+ spectrum[i] = spectrum[4]
1811
+
1812
+ # update new line(s) in image
1813
+ max_lines = len(self.area_scan_frame)
1814
+ for offset in range(self.settings.state.area_scan_line_step + 1):
1815
+ index = line_index + offset
1816
+ if 0 <= index < max_lines and index <= stop:
1817
+ self.area_scan_frame[index] = spectrum
1818
+
1819
+ ########################################################################
1820
+ # process completed frame
1821
+ ########################################################################
1822
+
1823
+ # store full-frame image data for Area Scan
1824
+ data = np.array(self.area_scan_frame, dtype=np.float32, copy=True)
1825
+ data -= data.min() # remove baseline
1826
+
1827
+ # vertically bin within vertical ROI for live graph
1828
+ log.debug(f"vertically binning ({start}, {stop})")
1829
+ cropped = data[start:(stop + 1), :]
1830
+ spectrum = np.sum(cropped, axis=0)
1831
+
1832
+ # flag the first and last 5pixels of the current line only
1833
+ if 0 <= line_index < max_lines:
1834
+ data[line_index][:5] = data.max()
1835
+ data[line_index][-5:] = data.max()
1836
+ asi = AreaScanImage(data=data, width=len(data[0]), height=len(data), line_index=line_index)
1837
+
1838
+ # return image and spectrum in Reading
1839
+ reading = Reading(self.device_id)
1840
+ reading.spectrum = spectrum
1841
+ reading.area_scan_image = asi
1842
+
1843
+ return reading
1844
+
1845
+ def get_area_scan_hamamatsu(self):
1846
+ """
1847
+ @returns Reading(spectrum, AreaScanImage(data=nd_array))
1848
+ """
1849
+ marker = 0xffff
1850
+ clamp = 0xfffe
1851
+ lines = 69
1852
+ line_len = self.settings.pixels() * 2
1853
+ timeout_ms = self.generate_timeout_ms()
1854
+
1855
+ # request a frame
1856
+ self._send_code(0xad, label="ACQUIRE_SPECTRUM")
1857
+
1858
+ # read frame line-by-line
1859
+ line_count = 0
1860
+ for line in range(lines):
1861
+ prefix = f"line {line_count}"
1862
+
1863
+ data = self.extra_area_scan_data # start with any extra data we might have picked up on the last read
1864
+ self.extra_area_scan_data = []
1865
+
1866
+ try:
1867
+ while len(data) < line_len:
1868
+ bytes_remaining = line_len - len(data)
1869
+ # @todo expand this to 0x86 for S16011-1101
1870
+ latest_data = self.device_type.read(self.device, 0x82, bytes_remaining, timeout=timeout_ms)
1871
+ data.extend(latest_data)
1872
+ except:
1873
+ log.error(f"get_area_scan_hamamatsu {prefix}: error reading line {line}", exc_info=1)
1874
+ break
1875
+
1876
+ extra_len = len(data) - line_len
1877
+ if extra_len > 0:
1878
+ log.warn(f"get_area_scan_hamamatsu {prefix}: storing {extra_len} extra bytes toward the next line")
1879
+ self.extra_area_scan_data = line_data[line_len:]
1880
+
1881
+ # demarshal line into spectrum
1882
+ spectrum = []
1883
+ for i in range(self.settings.pixels()):
1884
+ lsb = data[i*2 + 0]
1885
+ msb = data[i*2 + 1]
1886
+ intensity = (msb << 8) + lsb
1887
+ spectrum.append(intensity)
1888
+
1889
+ # first pixel is start-of-line marker
1890
+ if spectrum[0] != marker:
1891
+ log.warn(f"get_area_scan_hamamatsu {prefix}: first pixel expected 0x{marker:04x}, found 0x{spectrum[0]:04x}")
1892
+ spectrum[0] = spectrum[2] # stomp
1893
+
1894
+ # verify rest of line is clamped to 0xfffe
1895
+ for i, intensity in enumerate(spectrum):
1896
+ if i > 1 and spectrum[i] > clamp:
1897
+ log.warn(f"get_area_scan_hamamatsu {prefix}: WARNING: pixel {i:4d} value 0x{spectrum[i]:04x} exceeds clamp 0x{clamp:04x}")
1898
+
1899
+ # second pixel is line index
1900
+ line_index = spectrum[1]
1901
+ if line_index >= lines:
1902
+ log.warn(f"get_area_scan_hamamatsu {prefix}: WARNING: line_index {line_index} exceeds frame limit")
1903
+ spectrum[1] = spectrum[2] # stomp
1904
+
1905
+ # store line in image
1906
+ self.area_scan_frame[line_index] = spectrum
1907
+ # self.debug(f"{prefix}: stored line {line_index}: {spectrum[:5]}")
1908
+
1909
+ line_count += 1
1910
+
1911
+ ########################################################################
1912
+ # process completed frame
1913
+ ########################################################################
1914
+
1915
+ # store full-frame image data for Area Scan
1916
+ data = np.array(self.area_scan_frame, dtype=np.float32, copy=True)
1917
+ data -= data.min() # remove baseline
1918
+ asi = AreaScanImage(data=data, width=len(data[0]), height=len(data))
1919
+
1920
+ # vertically bin within vertical ROI for live graph
1921
+ start = self.settings.eeprom.roi_vertical_region_1_start
1922
+ stop = self.settings.eeprom.roi_vertical_region_1_end
1923
+ cropped = data[start:(stop + 1), :]
1924
+ spectrum = np.sum(cropped, axis=0)
1925
+
1926
+ # return image and spectrum in Reading
1927
+ reading = Reading(self.device_id)
1928
+ reading.spectrum = spectrum
1929
+ reading.area_scan_image = asi
1930
+
1931
+ return reading
1932
+
1933
+ # ##########################################################################
1934
+ # ADCs / DACs
1935
+ # ##########################################################################
1936
+
1937
+ # This is a little confusing, so:
1938
+ #
1939
+ # The "primary" ADC (ADC 0) is assumed to return laser temperature.
1940
+ #
1941
+ # The "secondary" ADC (ADC 1) may be wired to various other things for
1942
+ # legacy or OEM systems. For instance, it has been used with an integrated
1943
+ # photodiode for real-time measurement of laser output power (with or
1944
+ # without a calibration to convert the photodiode result to mW, hence
1945
+ # get_secondary_adc_calibrated).
1946
+ #
1947
+ # Most users will never need to explicitly call select_adc, and can simply
1948
+ # call get_laser_temperature_raw and/or get_laser_temperature_degC.
1949
+
1950
+ def get_dac(self, dacIndex: int = 0):
1951
+ return self._get_code(0xd9, wIndex=dacIndex, label="GET_DAC", lsb_len=2)
1952
+
1953
+ def select_adc(self, n: int):
1954
+ log.debug("select_adc -> %d", n)
1955
+ self.settings.state.selected_adc = n
1956
+ result = self._send_code(0xed, n, label="SELECT_ADC")
1957
+ self._get_code(0xd5, wLength=2, label="GET_ADC (throwaway)") # stabilization read
1958
+ return result
1959
+
1960
+ def get_secondary_adc_calibrated(self, raw: float = None):
1961
+ if not self.has_linearity_coeffs():
1962
+ log.debug("secondary_adc_calibrated: no calibration")
1963
+ return SpectrometerResponse(data=None, error_lvl=ErrorLevel.low, error_msg="secondary_adc_calibrated: no calibration")
1964
+
1965
+ if raw is None:
1966
+ response = self.get_secondary_adc_raw()
1967
+ if response.data is None:
1968
+ return response
1969
+
1970
+ raw = float(response.data)
1971
+
1972
+ # use the first 4 linearity coefficients as a 3rd-order polynomial
1973
+ calibrated = float(self.settings.eeprom.linearity_coeffs[0]) \
1974
+ + float(self.settings.eeprom.linearity_coeffs[1]) * raw \
1975
+ + float(self.settings.eeprom.linearity_coeffs[2]) * raw * raw \
1976
+ + float(self.settings.eeprom.linearity_coeffs[3]) * raw * raw * raw
1977
+ log.debug("secondary_adc_calibrated: %f", calibrated)
1978
+ response.data = calibrated
1979
+ return response
1980
+
1981
+ def get_secondary_adc_raw(self):
1982
+ # flip to secondary ADC if needed
1983
+ if self.settings.state.selected_adc is None or self.settings.state.selected_adc != 1:
1984
+ self.select_adc(1)
1985
+
1986
+ result = self._get_code(0xd5, wLength=2, label="GET_ADC", lsb_len=2)
1987
+ log.debug("secondary_adc_raw: 0x%04x", result)
1988
+ return result
1989
+
1990
+ # ##########################################################################
1991
+ # Laser Temperature
1992
+ # ##########################################################################
1993
+
1994
+ def set_laser_tec_mode(self, mode):
1995
+ if not self.settings.is_xs():
1996
+ msg = "Laser TEC mode only applicable on XS"
1997
+ log.error(msg)
1998
+ return SpectrometerResponse(data=None, error_lvl=ErrorLevel.low, error_msg=msg)
1999
+
2000
+ if isinstance(mode, str):
2001
+ mode = mode.upper()
2002
+ if mode == "OFF":
2003
+ mode = 0
2004
+ elif mode == "ON":
2005
+ mode = 1
2006
+ elif mode == "AUTO":
2007
+ mode = 2
2008
+ elif mode == "AUTO_ON":
2009
+ mode = 3
2010
+ else:
2011
+ mode = 0
2012
+
2013
+ mode = min(3, max(0, int(mode)))
2014
+ self.settings.state.laser_tec_mode = mode
2015
+ return self._send_code(0x84, mode, label="SET_LASER_TEC_MODE")
2016
+
2017
+ def get_laser_tec_mode(self):
2018
+ """
2019
+ The current opcode (0x85) seems to be actually returning
2020
+ GET_LASER_TEC_ENABLED, not GET_LASER_TEC_MODE.
2021
+ """
2022
+ if not self.settings.is_xs():
2023
+ msg = "Laser TEC mode only applicable on XS"
2024
+ log.error(msg)
2025
+ return SpectrometerResponse(data=None, error_lvl=ErrorLevel.low, error_msg=msg)
2026
+
2027
+ result = self._get_code(0x85, wLength=1, label="GET_LASER_TEC_MODE", lsb_len=1)
2028
+ if result is None or result.data < 0 or result.data > 3:
2029
+ msg = f"get_laser_tec_mode: invalid mode {result}"
2030
+ log.error(msg)
2031
+ return SpectrometerResponse(data=None, error_lvl=ErrorLevel.low, error_msg=msg)
2032
+
2033
+ # mode = result.data
2034
+ # names = [ 'OFF', 'ON', 'AUTO', 'AUTO_ON' ]
2035
+ # log.debug(f"get_laser_tec_mode: mode {names[mode]}")
2036
+ # self.settings.state.laser_tec_mode = mode
2037
+
2038
+ enabled = 0 != result.data
2039
+ # log.debug(f"get_laser_tec_mode: enabled {enabled}")
2040
+ self.settings.state.laser_tec_enabled = enabled
2041
+
2042
+ return result
2043
+
2044
+ ##
2045
+ # Laser temperature conversion doesn't use EEPROM coeffs at all.
2046
+ #
2047
+ # @par IPS
2048
+ #
2049
+ # Some Wasatch SML products use an IPS Wavelength-Stabilized TO-56
2050
+ # laser, which internally uses a Betatherm 10K3CG3 thermistor.
2051
+ #
2052
+ # @see https://www.ipslasers.com/data-sheets/SM-TO-56-Data-Sheet-IPS.pdf
2053
+ #
2054
+ # The official IPS conversion from thermistor resistance (in ohms) to degC is:
2055
+ #
2056
+ # \verbatim
2057
+ # 1 / ( C1
2058
+ # + C2 * ln(ohms)
2059
+ # + C3 * pow(ln(ohms), 3)
2060
+ # )
2061
+ # - 273.15
2062
+ #
2063
+ # Where: C1 = 0.00113
2064
+ # C2 = 0.000234
2065
+ # C3 = 8.78e-8
2066
+ # \endverbatim
2067
+ #
2068
+ # That said, on XS the IPS thermistor then goes through a MAX1978ETM-T
2069
+ # TEC IC which buffers the thermistor voltage. This requires the additional
2070
+ # empirically-determined static conversion captured below.
2071
+ #
2072
+ # Regrettably, the laser thermistor voltage on 110280 boards
2073
+ # overflows the ADC range, so we don't currently have valid laser
2074
+ # temperature read-out with those PCBs.
2075
+ #
2076
+ # @par Ondax MML
2077
+ #
2078
+ # On MML units using the Ondax OEM Module, the cable to the 220060
2079
+ # does not seem to pass-through the thermistor pin, so we don't have
2080
+ # laser temperature read-out on those, either. (Nor photodiode...)
2081
+ #
2082
+ # @par RealLight
2083
+ #
2084
+ # RealLight SML thermistors use the following resistance and beta:
2085
+ #
2086
+ # Thermistor Rt (kΩ / β(25°C)): 10±5% / 3450
2087
+ #
2088
+ # Therefore you can convert the raw thermistor ADC reading to degrees Celsius:
2089
+ #
2090
+ # 1. Convert raw ADC to voltage:
2091
+ #
2092
+ # voltage = 2.5 * raw / 4096
2093
+ #
2094
+ # 2. Convert voltage to resistance (assuming 2.5v reference)
2095
+ #
2096
+ # resistance = 21450.0 * voltage / (2.5 - voltage)
2097
+ #
2098
+ # 3. Convert resistance to Kelvin:
2099
+ #
2100
+ # 1/T = (1/T0) + (1/β) * ln(R/R0)
2101
+ # 1 = T((1/T0) + (1/β) * ln(R/10))
2102
+ # T = 1/((1/T0) + (1/β) * ln(R/10))
2103
+ #
2104
+ # Where:
2105
+ #
2106
+ # T: is the temperature in Kelvin
2107
+ # T0: is a reference temperature (usually 25°C or 298.15K)
2108
+ # β: is the beta value of the thermistor (a constant indicating the relationship between resistance and temperature)
2109
+ # R: is the measured resistance of the thermistor
2110
+ # R0: is the resistance of the thermistor at the reference temperature T0
2111
+ #
2112
+ # Kelvin = 1/((1/298.15) + (1/3450) * ln(resistance/10))
2113
+ #
2114
+ # 4. Convert Kelvin to degrees Celsius:
2115
+ #
2116
+ # degC = Kelvin - 273.15
2117
+ #
2118
+ # @param raw the value read from the thermistor's 12-bit ADC (can be
2119
+ # uint12 or SpectrometerResponse)
2120
+ def get_laser_temperature_degC(self, raw=None):
2121
+ if isinstance(raw, SpectrometerResponse):
2122
+ raw = raw.data
2123
+ if raw is None:
2124
+ raw = self.get_laser_temperature_raw()
2125
+
2126
+ if raw is None:
2127
+ msg="get_laser_temperature_degC: error reading raw laser temperature"
2128
+ log.error(msg)
2129
+ return SpectrometerResponse(error_lvl=ErrorLevel.low, error_msg=msg)
2130
+
2131
+ if raw > 0xfff:
2132
+ msg = f"get_laser_temperature_degC: raw value 0x{raw:x} exceeds 12 bits"
2133
+ log.error(msg)
2134
+ return SpectrometerResponse(data=None, error_lvl=ErrorLevel.low, error_msg=msg)
2135
+
2136
+ if raw == 0:
2137
+ msg = "get_laser_temperature_degC: can't take log of raw ADC value 0"
2138
+ # log.debug(msg)
2139
+ return SpectrometerResponse(data=None) # not propogating error_msg for now
2140
+
2141
+ degC = 0
2142
+ try:
2143
+ if self.settings.is_xs():
2144
+ # conversion for 220250 Rev4 MAX1978ETM-T 3V3 buffer -> 12-bit DAC -> degC
2145
+ coeffs = [ 1.5712971947853123e+000,
2146
+ 1.4453391889061071e-002,
2147
+ -1.8534086153440592e-006,
2148
+ 4.2553356470494626e-010 ]
2149
+ for i, coeff in enumerate(coeffs):
2150
+ degC += coeff * pow(raw, i)
2151
+ # log.debug(f"get_laser_temperature_degC: degC {degC:g} after adding {coeff} x (pow({raw}, {i}) = {pow(raw, i):g})")
2152
+ else:
2153
+ # when not using TEC IC
2154
+ voltage = 2.5 * raw / 4096
2155
+ resistance = 21450.0 * voltage / (2.5 - voltage) # LB confirmed
2156
+
2157
+ if resistance < 0:
2158
+ msg="get_laser_temperature_degC: can't compute degC: raw = 0x%04x, voltage = %f, resistance = %f" % (
2159
+ raw, voltage, resistance)
2160
+ log.error(msg)
2161
+ return SpectrometerResponse(data=None, error_level=ErrorLevel.low, error_msg=msg)
2162
+
2163
+ logVal = math.log(resistance / 10000.0)
2164
+ insideMain = logVal + 3977.0 / (25 + 273.0)
2165
+ degC = 3977.0 / insideMain - 273.0
2166
+
2167
+ log.debug("Laser temperature: %.2f deg C (0x%04x raw)" % (degC, raw))
2168
+ except:
2169
+ msg = "exception computing laser temperature"
2170
+ log.error(msg, exc_info=1)
2171
+ return SpectrometerResponse(data=None, error_level=ErrorLevel.low, error_msg=msg)
2172
+
2173
+ return SpectrometerResponse(data=degC)
2174
+
2175
+ # ##########################################################################
2176
+ # Detector Temperature
2177
+ # ##########################################################################
2178
+
2179
+ ## @note big-endian, reverse of get_laser_temperature_raw
2180
+ def get_detector_temperature_raw(self):
2181
+ return self._get_code(0xd7, label="GET_CCD_TEMP", msb_len=2)
2182
+
2183
+ def get_detector_temperature_degC(self, raw: float = None):
2184
+ if raw is None:
2185
+ raw = self.get_detector_temperature_raw().data
2186
+
2187
+ if raw is None:
2188
+ return raw
2189
+
2190
+ degC = self.settings.eeprom.adc_to_degC_coeffs[0] \
2191
+ + self.settings.eeprom.adc_to_degC_coeffs[1] * raw \
2192
+ + self.settings.eeprom.adc_to_degC_coeffs[2] * raw * raw
2193
+ log.debug("Detector temperature: %.2f deg C (0x%04x raw)" % (degC, raw))
2194
+ return SpectrometerResponse(data=degC)
2195
+
2196
+ def set_detector_tec_setpoint_degC(self, degC: float):
2197
+ """
2198
+ Attempt to set the CCD cooler setpoint. Verify that it is within an
2199
+ acceptable range. Ideally this is to prevent condensation and other
2200
+ issues. This value is a default and is hugely dependent on the
2201
+ environmental conditions.
2202
+ """
2203
+ if not self.settings.eeprom.has_cooling:
2204
+ log.error("unable to control TEC: EEPROM reports no cooling")
2205
+ return SpectrometerResponse(data=False, error_lvl=ErrorLevel.low, error_msg="unable to control TEC: EEPROM reports no cooling")
2206
+
2207
+ if degC < self.settings.eeprom.min_temp_degC:
2208
+ log.critical("set_detector_tec_setpoint_degC: setpoint %f below min %f", degC, self.settings.eeprom.min_temp_degC)
2209
+ return SpectrometerResponse(data=False, error_lvl=ErrorLevel.low, error_msg="setpoint below minimum")
2210
+
2211
+ if degC > self.settings.eeprom.max_temp_degC:
2212
+ log.critical("set_detector_tec_setpoint_degC: setpoint %f exceeds max %f", degC, self.settings.eeprom.max_temp_degC)
2213
+ return SpectrometerResponse(data=False, error_lvl=ErrorLevel.low, error_msg="setpoint beyond max")
2214
+
2215
+ raw = int(round(self.settings.eeprom.degC_to_dac_coeffs[0]
2216
+ + self.settings.eeprom.degC_to_dac_coeffs[1] * degC
2217
+ + self.settings.eeprom.degC_to_dac_coeffs[2] * degC * degC))
2218
+
2219
+ # ROUND (don't mask) to 12-bit DAC
2220
+ raw = max(0, min(raw, 0xfff))
2221
+
2222
+ log.debug("Set CCD TEC Setpoint: %.2f deg C (raw ADC 0x%04x)", degC, raw)
2223
+ ok = self._send_code(0xd8, raw, label="SET_DETECTOR_TEC_SETPOINT")
2224
+ self.settings.state.tec_setpoint_degC = degC
2225
+ self.detector_tec_setpoint_has_been_set = True
2226
+ return ok
2227
+
2228
+ def get_detector_tec_setpoint_degC(self):
2229
+ if self.detector_tec_setpoint_has_been_set:
2230
+ return SpectrometerResponse(self.settings.state.tec_setpoint_degC)
2231
+ log.error("Detector TEC setpoint has not yet been applied")
2232
+ return SpectrometerResponse(data=0.0)
2233
+
2234
+ def get_detector_tec_setpoint_raw(self):
2235
+ return self.get_dac(0)
2236
+
2237
+ ## @todo rename set_detector_tec_enable
2238
+ def set_tec_enable(self, flag: bool):
2239
+ if not self.settings.eeprom.has_cooling:
2240
+ log.debug("unable to control TEC: EEPROM reports no cooling")
2241
+ return SpectrometerResponse(data=False, error_msg="unable to control TEC: EEPROM reports no cooling")
2242
+
2243
+ if not self.settings.eeprom.has_detector_tec_calibration():
2244
+ log.debug("unable to control TEC: EEPROM missing valid TEC calibration")
2245
+ return SpectrometerResponse(data=False, error_msg="unable to control TEC: EEPROM missing valid TEC calibration")
2246
+
2247
+ value = 1 if flag else 0
2248
+
2249
+ if not self.detector_tec_setpoint_has_been_set:
2250
+
2251
+ # @todo should this not be eeprom.startup_temp_degC
2252
+ log.debug("defaulting TEC setpoint to min %s", self.settings.eeprom.min_temp_degC)
2253
+ self.set_detector_tec_setpoint_degC(self.settings.eeprom.min_temp_degC)
2254
+
2255
+ log.debug("Send detector TEC enable: %s", value)
2256
+ ok = self._send_code(0xd6, value, label="SET_DETECTOR_TEC_ENABLE")
2257
+ if ok.data:
2258
+ self.settings.state.tec_enabled = flag
2259
+ return ok
2260
+
2261
+ # ##########################################################################
2262
+ # Triggering
2263
+ # ##########################################################################
2264
+
2265
+ def set_trigger_source(self, value: bool):
2266
+ """
2267
+ Set the source for incoming acquisition triggers.
2268
+ @param value either 0 for "internal" or 1 for "external"
2269
+ With internal triggering (the default), the spectrometer expects the
2270
+ USB host to explicitly send a START_ACQUISITION (ACQUIRE) opcode to
2271
+ begin each integration. In external triggering, the spectrometer
2272
+ waits for the rising edge on a signal connected to a pin on the OEM
2273
+ accessory connector.
2274
+ Technically on ARM, the microcontroller is continuously monitoring
2275
+ both the external pin and listening for internal software opcodes.
2276
+ On the FX2 you need to explicitly place the microcontroller into
2277
+ external triggering mode to avail the feature.
2278
+ """
2279
+ self.settings.state.trigger_source = value
2280
+ log.debug("trigger_source now %s", value)
2281
+
2282
+ # Don't send the opcode on ARM. See issue #2 on WasatchUSB project
2283
+ if self.settings.is_arm():
2284
+ return SpectrometerResponse(data=False)
2285
+
2286
+ msb = 0
2287
+ lsb = value
2288
+ buf = [0] * 8
2289
+
2290
+ # MZ: this is weird...we're sending the buffer on an FX2-only command
2291
+ return self._send_code(0xd2, lsb, msb, buf, label="SET_TRIGGER_SOURCE")
2292
+
2293
+ # ##########################################################################
2294
+ # High-Gain Mode
2295
+ # ##########################################################################
2296
+
2297
+ ##
2298
+ # CF_SELECT is configured using bit 2 of the FPGA configuration register
2299
+ # 0x12. This bit can be set using vendor commands 0xeb to SET and 0xec
2300
+ # to GET. Note that the set command is expecting a 5-byte unsigned
2301
+ # value, the highest byte of which we pass as part of an 8-byte buffer.
2302
+ # Not sure why.
2303
+ def set_high_gain_mode_enable(self, flag: bool):
2304
+ log.debug("Set high gain mode: %s", flag)
2305
+ if not self.settings.is_ingaas():
2306
+ log.debug("SET_HIGH_GAIN_MODE_ENABLE only supported on InGaAs")
2307
+ return SpectrometerResponse(data=False, error_msg="High Gain not support on this spectrometer")
2308
+
2309
+ value = 1 if flag else 0
2310
+
2311
+ # this is done automatically on ARM, but for this opcode we do it on FX2 as well
2312
+ buf = [0] * 8
2313
+
2314
+ self.settings.state.high_gain_mode_enabled = flag
2315
+ return self._send_code(0xeb, wValue=value, wIndex=0, data_or_wLength=buf, label="SET_HIGH_GAIN_MODE_ENABLE")
2316
+
2317
+ def get_high_gain_mode_enabled(self):
2318
+ if not self.settings.is_ingaas():
2319
+ log.debug("GET_HIGH_GAIN_MODE_ENABLE only supported on InGaAs")
2320
+ return SpectrometerResponse(self.settings.eeprom.high_gain_mode_enabled)
2321
+
2322
+ self.settings.state.high_gain_mode_enabled = 0 != self._get_code(0xec, lsb_len=1, label="GET_HIGH_GAIN_MODE_ENABLED")
2323
+ return SpectrometerResponse(self.settings.state.high_gain_mode_enabled)
2324
+
2325
+ ############################################################################
2326
+ # Laser commands
2327
+ ############################################################################
2328
+
2329
+ def get_opt_laser_control(self):
2330
+ return self.get_upper_code(0x09, label="GET_OPT_LASER_CONTROL", msb_len=1)
2331
+
2332
+ def get_opt_has_laser(self):
2333
+ value = self.get_upper_code(0x08, label="GET_OPT_HAS_LASER", msb_len=1).data
2334
+ available = 0 != value
2335
+ if available != self.settings.eeprom.has_laser:
2336
+ log.error("OPT_HAS_LASER opcode result %s != EEPROM has_laser %s (using opcode)",
2337
+ value, self.settings.eeprom.has_laser)
2338
+ return SpectrometerResponse(data=available)
2339
+
2340
+ def get_laser_temperature_raw(self):
2341
+ """ @note little-endian, reverse of get_detector_temperature_raw """
2342
+ # flip to primary ADC if needed
2343
+ if self.settings.state.selected_adc is None or self.settings.state.selected_adc != 0:
2344
+ # MZ: unclear if this is needed on SiG
2345
+ self.select_adc(0)
2346
+
2347
+ result = self._get_code(0xd5, wLength=2, label="GET_ADC", lsb_len=2)
2348
+ if not result.data:
2349
+ log.debug("Unable to read laser temperature")
2350
+ return SpectrometerResponse(0)
2351
+ result.data = result.data & 0xfff # clamp to 12-bit
2352
+ return result
2353
+
2354
+ def set_selected_laser(self, value: int):
2355
+ """
2356
+ On spectrometers supporting two lasers, select the primary (0) or
2357
+ secondary (1). Laser Enable, laser power etc should all then
2358
+ affect the currently-selected laser.
2359
+ @warning conflicts with GET_RAMAN_MODE_ENABLE
2360
+ """
2361
+ if not self.settings.eeprom.has_laser:
2362
+ msg = "no laser installed"
2363
+ log.error(msg)
2364
+ return SpectrometerResponse(data=False, error_msg=msg)
2365
+
2366
+ n = 1 if value else 0
2367
+ log.debug("selecting laser %d", n)
2368
+ self.settings.state.selected_laser = n
2369
+
2370
+ return self._send_code(bRequest = 0xff,
2371
+ wValue = 0x15,
2372
+ wIndex = n,
2373
+ data_or_wLength = [0] * 8,
2374
+ label = "SET_SELECTED_LASER")
2375
+
2376
+ def get_selected_laser(self):
2377
+ return SpectrometerResponse(data=self.settings.state.selected_laser)
2378
+
2379
+ def get_laser_enabled(self):
2380
+ if self.settings.is_xs():
2381
+ # MZ: get_laser_enabled not currently robust on SiG :-(
2382
+ return SpectrometerResponse(data=self.settings.state.laser_enabled)
2383
+
2384
+ res = self._get_code(0xe2, label="GET_LASER_ENABLED", msb_len=1)
2385
+ flag = 0 != res.data
2386
+
2387
+ log.debug(f"get_laser_enabled: {flag} (storing to {self.settings.state})")
2388
+ self.settings.state.laser_enabled = flag
2389
+ return SpectrometerResponse(data=flag)
2390
+
2391
+ def set_laser_enable(self, flag: bool):
2392
+ """
2393
+ Turn the laser on or off.
2394
+ If laser power hasn't yet been externally configured, applies the default
2395
+ of full-power. The new state will be applied immediately.
2396
+ @param flag (Input) bool (True turns laser on, False turns laser off)
2397
+ @returns whether the new state was applied
2398
+ """
2399
+ if not self.settings.eeprom.has_laser:
2400
+ msg = "no laser installed"
2401
+ log.error(msg)
2402
+ return SpectrometerResponse(data=False, error_msg=msg)
2403
+
2404
+ # ARM seems to require that laser power be set before the laser is enabled
2405
+ if self.next_applied_laser_power is None:
2406
+ self.set_laser_power_perc(100.0)
2407
+
2408
+ self.settings.state.laser_enabled = flag
2409
+ self._set_laser_enable_immediate(flag)
2410
+ return SpectrometerResponse(data=True)
2411
+
2412
+ def _set_laser_enable_immediate(self, flag: bool):
2413
+ """
2414
+ The user has requested to update the laser firing state (on or off),
2415
+ so apply the new laser state to the spectrometer immediately.
2416
+
2417
+ Because the ability to immediately disable a laser is a safety-related
2418
+ feature (noting that truly safety-critical capabilities should be
2419
+ implemented in hardware, and generally can't be robustly achieved through
2420
+ Python scripts), this function takes the unusual step of looping over
2421
+ multiple attempts to set the laser state until either the command succeeds,
2422
+ or 3 consecutive failures have occured.
2423
+
2424
+ This behavior was added after a developmental, unreleased prototype was
2425
+ found to occasionally drop USB packets, and was therefore susceptible to
2426
+ inadvertently failing to disable the laser upon command.
2427
+
2428
+ @private (as callers are recommended to use set_laser_enable)
2429
+ @param flag (Input) whether the laser should be on (true) or off (false)
2430
+ @returns true if new state was successfully applied
2431
+ """
2432
+ log.debug("Send laser enable: %s", flag)
2433
+ if flag:
2434
+ self.last_applied_laser_power = 0.0
2435
+ else:
2436
+ self.last_applied_laser_power = self.next_applied_laser_power
2437
+
2438
+ self.set_strobe_enable(flag)
2439
+
2440
+ if self.settings.is_xs():
2441
+ # Series-XS getLaserEnable doesn't provide immediate confirmation
2442
+ # because it's comingled with laserWatchdogSec and probably
2443
+ # laserDelaySec
2444
+ return SpectrometerResponse(data=True)
2445
+
2446
+ # try to verify laserEnable worked
2447
+ tries = 0
2448
+ while True:
2449
+ res = self.get_laser_enabled()
2450
+ if flag == res.data:
2451
+ return SpectrometerResponse(data=True)
2452
+ tries += 1
2453
+ if tries > 3:
2454
+ log.critical("laser_enable %s command failed, giving up", flag)
2455
+ self.queue_message("marquee_error", "laser setting failed")
2456
+ return SpectrometerResponse(data=False, error_msg="laser setting failed", error_lvl=ErrorLevel.medium)
2457
+ else:
2458
+ log.error("laser_enable %s command failed, re-trying", flag)
2459
+ self.set_strobe_enable(flag)
2460
+
2461
+ def set_laser_power_mW(self, mW_in: int):
2462
+ if mW_in is None or not self.settings.eeprom.has_laser_power_calibration():
2463
+ log.error("EEPROM doesn't have laser power calibration")
2464
+ self.settings.state.laser_power_mW = 0
2465
+ self.settings.state.laser_power_perc = 0
2466
+ self.settings.state.use_mW = False
2467
+ return SpectrometerResponse(data=False, error_msg="no laser power calibration")
2468
+
2469
+ mW = min(self.settings.eeprom.max_laser_power_mW, max(self.settings.eeprom.min_laser_power_mW, mW_in))
2470
+
2471
+ perc = self.settings.eeprom.laser_power_mW_to_percent(mW)
2472
+ log.debug("set_laser_power_mW: range (%.2f, %.2f), requested %.2f, approved %.2f, percent = %.2f",
2473
+ self.settings.eeprom.min_laser_power_mW,
2474
+ self.settings.eeprom.max_laser_power_mW,
2475
+ mW_in,
2476
+ mW,
2477
+ perc)
2478
+ self.settings.state.laser_power_mW = mW
2479
+ return self.set_laser_power_perc(perc, set_in_perc=False)
2480
+
2481
+ ##
2482
+ # @brief Sets whether the PWM pulse period is 1000µs (high-resolution, default)
2483
+ # or 100µs (low-resolution, legacy).
2484
+ #
2485
+ # @param flag (Input) True for 1000µs PWM period (high-resolution, default),
2486
+ # False for 100µs (low-resolution)
2487
+ #
2488
+ # Laser power is controlled via PWM (Pulse Width Modulation), essentially
2489
+ # a square wave whose "duty cycle" (high vs low), expressed as a percentage,
2490
+ # represents the amount of time the laser is actually firing. If the duty
2491
+ # cycle is 33%, the laser will be firing 1/3 of the time, representing
2492
+ # approximately one-third of "full power".
2493
+ #
2494
+ # (It's not "exactly" one-third because the laser needs a moment to
2495
+ # stablize when energized, and that "start-up" instability is increased
2496
+ # when you're constantly pulsing the laser on-and-off.)
2497
+ #
2498
+ # This function is used to determine the LENGTH (period) of that PWM
2499
+ # square wave. Because the laser's PWM parameters are all specified in
2500
+ # microseconds (µs), a longer period allows greater precision (resolution)
2501
+ # in specifying the duty cycle.
2502
+ #
2503
+ # Historically, Wasatch spectrometers used a PWM period of 100µs, meaning
2504
+ # the pulse width (time each wave was high) could only be set from 1-99µs,
2505
+ # giving an essential laser power resolution of 1%. That behavior can
2506
+ # be restored by setting this value False.
2507
+ #
2508
+ # More recently, we've increased the default PWM pulse period to 1000µs
2509
+ # (1ms). Since the pulse width is still set in µs, that allows an
2510
+ # effective laser power resolution of 0.1%. This is the new default,
2511
+ # and can be explicitly requested by setting this value to True.
2512
+ #
2513
+ # @warning Note that this function is provided for cases where the laser power
2514
+ # is set as a percentage. When setting laser power through milliWatts, using
2515
+ # the onboard laser power calibration, it is important to use the same
2516
+ # resolution as was in effect when the calibration was generated. All Wasatch
2517
+ # laser power calibrations are generated in "high-resolution," so this
2518
+ # function SHOULD NOT be set "False" (low-res) if setting laser power in mW.
2519
+ def set_laser_power_high_resolution(self, flag: bool):
2520
+ self.settings.state.laser_power_high_resolution = True if flag else False
2521
+ return SpectrometerResponse()
2522
+
2523
+ def set_laser_power_require_modulation(self, flag: bool):
2524
+ self.settings.state.laser_power_require_modulation = True if flag else False
2525
+ return SpectrometerResponse()
2526
+
2527
+ ##
2528
+ # @todo support floating-point value, as we have a 12-bit ADC and can provide
2529
+ # a bit more precision than 100 discrete steps (goal to support 0.1 - .125% resolution)
2530
+ def set_laser_power_perc(self, value_in: float, set_in_perc: bool = True):
2531
+ if not self.settings.eeprom.has_laser:
2532
+ msg = "no laser installed"
2533
+ log.error(msg)
2534
+ return SpectrometerResponse(data=False, error_msg=msg)
2535
+
2536
+ value = float(max(0, min(100, value_in)))
2537
+ self.settings.state.laser_power_perc = value
2538
+ log.debug("set_laser_power_perc: range (0, 100), requested %.2f, applying %.2f", value_in, value)
2539
+
2540
+ if set_in_perc:
2541
+ # apparently the laser power was explicitly commanded as a percentage
2542
+ # of full, and not "computed from mW" using a calibration, so clear
2543
+ # the mW setpoint
2544
+ self.settings.state.laser_power_mW = 0
2545
+
2546
+ return self.set_laser_power_perc_immediate(value)
2547
+
2548
+ ##
2549
+ # Sets laser power (modulated pulse width) which will be used next time the
2550
+ # laser is turned on (or changed immediately, if the laser is already enabled).
2551
+ # Laser power is determined by a combination of the pulse width,
2552
+ # period and modulation being enabled. There are many combinations of
2553
+ # these values that will produce a given percentage of the total laser
2554
+ # power through pulse width modulation. There is no 'get laser power'
2555
+ # control message on the device.
2556
+ #
2557
+ # Some of the goals of Enlighten are for it to be stable, and a reason
2558
+ # we have sales. During spectrometer builds, it was discovered that
2559
+ # the laser power settings were not implemented. During the
2560
+ # implementation process, it was discovered that the laser modulation,
2561
+ # pulse period and pulse width commands do not conform to
2562
+ # specification. Where you can set integration time 100 ms with the
2563
+ # command:
2564
+ #
2565
+ # device.ctrl_transfer(bmRequestType=device_to_host,
2566
+ # bRequest=0xDB,
2567
+ # wValue=100,
2568
+ # wIndex=0,
2569
+ # data_or_wLength=0)
2570
+ #
2571
+ # The laser pulse period must be set where the wValue and
2572
+ # data_or_wLength parameters are equal. So if you wanted a pulse
2573
+ # period of 100, you must specify the value in both places:
2574
+ #
2575
+ # ...
2576
+ # wValue=100,
2577
+ # data_or_wLength=100)
2578
+ # ...
2579
+ #
2580
+ # This in turn implies that the legacy firmware has a long masked
2581
+ # issue when reading the value to update from the data_or_wLength
2582
+ # parameter instead of the wValue field. This is only accurate for the
2583
+ # laser modulation related functions.
2584
+ #
2585
+ # This is backed up by the Dash v3 StrokerControl DLL implementation.
2586
+ # It was discovered that the StrokerControl DLL sets the wValue and
2587
+ # data or wLength parameters to the same value at every control
2588
+ # message write.
2589
+ #
2590
+ # The exciting takeaway here is that Enlighten is stable enough.
2591
+ # Turning the laser on with the data or wLength parameter not set
2592
+ # correctly will cause a hardware failure and complete device lockup
2593
+ # requiring a power cycle.
2594
+ #
2595
+ # fid:
2596
+ # CRITICAL Hardware Failure FID Send Code Problem with
2597
+ # ctrl transfer: [Errno None] 11
2598
+ #
2599
+ # Unlike Dash which may lockup and require killing the application,
2600
+ # Enlighten does not lock up. The Enlighten code base has now been
2601
+ # used to unmask an issue that has been lurking with our legacy
2602
+ # firmware for close to 6 years. We've detected this out of
2603
+ # specification area of the code before it can adversely impact a
2604
+ # customer. """
2605
+ #
2606
+ # As long as laser power is modulated using a period of 100us,
2607
+ # with a necessarily-integral pulse width of 1-99us, then it's
2608
+ # not physically possible to support fractional power levels.
2609
+ #
2610
+ # @todo talk to Jason about changing modulation PERIOD to longer
2611
+ # value (200us? 400? 1000?), OR whether pulse WIDTH can be
2612
+ # in smaller unit (500ns? 100ns?)
2613
+ def set_laser_power_perc_immediate(self, value: float):
2614
+
2615
+ # laser can flicker if we're on the wrong ADC?
2616
+
2617
+ # don't want anything weird when passing over USB
2618
+ value = float(max(0, min(100, value)))
2619
+ if value <= 0:
2620
+ return set_laser_enable(False)
2621
+
2622
+ # If full power (and allowed), disable modulation and exit
2623
+ if value >= 100:
2624
+ if self.settings.state.laser_power_require_modulation:
2625
+ log.debug("100% power requested, yet laser modulation required, so not disabling modulation")
2626
+ else:
2627
+ log.debug("Turning off laser modulation (full power)")
2628
+ self.next_applied_laser_power = 100.0
2629
+ log.debug("next_applied_laser_power = 100.0")
2630
+ return self.set_mod_enable(False)
2631
+
2632
+ period_us = 1000 if self.settings.state.laser_power_high_resolution else 100
2633
+ width_us = int(round(1.0 * value * period_us / 100.0, 0)) # note that value is in range (0, 100) not (0, 1)
2634
+
2635
+ if width_us <= 0:
2636
+ return set_laser_enable(False)
2637
+
2638
+ # pulse width can't be longer than period, or shorter than 1us
2639
+ width_us = max(1, min(width_us, period_us))
2640
+
2641
+ # Change the pulse period. Note that we're not parsing into 40-bit
2642
+ # because this implementation is hard-coded to either 100 or 1000us
2643
+ # (both fitting well within uint16)
2644
+ result = self.set_mod_period_us(period_us)
2645
+ # if result.data is None:
2646
+ # log.critical("Hardware Failure to send laser mod. pulse period")
2647
+ # return SpectrometerResponse(data=False, error_msg="failed to send laser mod")
2648
+
2649
+ # Set the pulse width to the 0-100 percentage of power
2650
+ result = self.set_mod_width_us(width_us)
2651
+ # if result.data is None:
2652
+ # log.critical("Hardware Failure to send pulse width")
2653
+ # return SpectrometerResponse(data=False, error_msg="failed to send pulse width")
2654
+
2655
+ # Enable modulation
2656
+ result = self.set_mod_enable(True)
2657
+ # if result.data is None:
2658
+ # log.critical("Hardware Failure to send laser modulation")
2659
+ # return SpectrometerResponse(data=False, error_msg="failed to send laser modulation")
2660
+
2661
+ log.debug("Laser power set to: %d", value)
2662
+
2663
+ self.next_applied_laser_power = value
2664
+ log.debug("next_applied_laser_power = %s", self.next_applied_laser_power)
2665
+
2666
+ return result
2667
+
2668
+ ##
2669
+ # @note never used, provided for OEM
2670
+ def get_laser_temperature_setpoint_raw(self):
2671
+ if not self.settings.eeprom.has_laser:
2672
+ msg = "no laser installed"
2673
+ log.error(msg)
2674
+ return SpectrometerResponse(data=None, error_msg=msg)
2675
+
2676
+ # is this little-endian?
2677
+ lsb_len = 2 if self.settings.is_xs() else 1
2678
+
2679
+ return self._get_code(0xe8, lsb_len=lsb_len, label="GET_LASER_TEC_SETPOINT")
2680
+
2681
+ def set_laser_temperature_setpoint_raw(self, value: int):
2682
+ log.debug(f"Send laser temperature setpoint raw: 0x{value:03x}")
2683
+ return self._send_code(0xe7, value, label="SET_LASER_TEC_SETPOINT")
2684
+
2685
+ def set_laser_warning_delay_sec(self, value):
2686
+ if not self.settings.is_xs():
2687
+ log.error("laser warning delay only configurable on XS")
2688
+ return
2689
+ return self._send_code(0x8a, value, label="SET_LASER_WARNING_DELAY_SEC")
2690
+
2691
+ def get_laser_warning_delay_sec(self):
2692
+ if not self.settings.is_xs():
2693
+ msg = "laser warning delay only configurable on XS"
2694
+ log.error(msg)
2695
+ return SpectrometerResponse(data=None, error_msg=msg)
2696
+
2697
+ if not self.settings.supports_feature("get_laser_warning_delay_sec"):
2698
+ msg = "GET_LASER_WARNING_DELAY_SEC not supported on this firmware"
2699
+ log.error(msg)
2700
+ return SpectrometerResponse(data=None, error_msg=msg)
2701
+
2702
+ result = self._get_code(0x8b, lsb_len=1, label="GET_LASER_WARNING_DELAY_SEC")
2703
+ self.settings.state.laser_warning_delay_sec = result.data
2704
+ return result
2705
+
2706
+ ############################################################################
2707
+ # digital pot on 220250 Rev4A+
2708
+ ############################################################################
2709
+
2710
+ def set_laser_power_attenuator(self, value):
2711
+ cmd = "SET_LASER_ATTENUATOR"
2712
+ if not self.settings.is_xs():
2713
+ msg = f"{cmd} is only available on XS-Series with 220250 Rev4A+"
2714
+ log.debug(msg)
2715
+ return SpectrometerResponse(data=None, error_msg=msg)
2716
+
2717
+ # digital potentiometer is 8-bit
2718
+ value = max(0, min(0xff, int(round(value))))
2719
+
2720
+ return self._send_code(0x82, value, label=cmd)
2721
+
2722
+ def get_laser_power_attenuator(self):
2723
+ cmd = "GET_LASER_ATTENUATOR"
2724
+ if not self.settings.is_xs():
2725
+ msg = f"{cmd} is only available on XS-Series with 220250 Rev4A+"
2726
+ log.debug(msg)
2727
+ return SpectrometerResponse(data=None, error_msg=msg)
2728
+
2729
+ return self._get_code(0x83, msb_len=1, label=cmd)
2730
+
2731
+ ############################################################################
2732
+ # laser interlock
2733
+ ############################################################################
2734
+
2735
+ def get_laser_interlock(self):
2736
+ """ Legacy wrapper over can_laser_fire. """
2737
+ return self.can_laser_fire()
2738
+
2739
+ def can_laser_fire(self):
2740
+ """
2741
+ @note only works on FX2-based spectrometers with FW >= 10.0.0.11
2742
+ @returns True if there is a laser and either the interlock is
2743
+ closed (in firing position), or there is no readable
2744
+ interlock
2745
+ """
2746
+ if not self.settings.eeprom.has_laser:
2747
+ msg = "no laser installed"
2748
+ log.error(msg)
2749
+ return SpectrometerResponse(data=False, error_msg=msg)
2750
+
2751
+ if not self.settings.eeprom.has_interlock_feedback:
2752
+ # log.debug("CAN_LASER_FIRE requires has_interlock_feedback (defaulting True)")
2753
+ return SpectrometerResponse(data=True)
2754
+
2755
+ res = self._get_code(0xef, label="CAN_LASER_FIRE", msb_len=1)
2756
+ res.data = 0 != res.data
2757
+ return res
2758
+
2759
+ def is_laser_firing(self):
2760
+ """
2761
+ Check if the laser actually IS firing, independent of laser_enable or
2762
+ can_laser_fire.
2763
+
2764
+ @returns SpectrometerResponse
2765
+ """
2766
+ if not self.settings.eeprom.has_interlock_feedback:
2767
+ # log.debug("IS_LASER_FIRING requires has_interlock_feedback (defaulting to laser_enabled)")
2768
+ return self.get_laser_enabled()
2769
+
2770
+ res = self.get_upper_code(0x0d, label="IS_LASER_FIRING", msb_len=1)
2771
+ res.data = 0 != res.data
2772
+ return res
2773
+
2774
+ ############################################################################
2775
+ # (end of laser commands)
2776
+ ############################################################################
2777
+
2778
+ def reset_fpga(self):
2779
+ log.debug("reset_Fpga: start")
2780
+ result = self._send_code(0xb5, label="RESET_FPGA")
2781
+
2782
+ self.queue_message("marquee_error", "resetting FPGA")
2783
+
2784
+ log.debug("reset_fpga: sleeping 3sec")
2785
+ sleep(3)
2786
+
2787
+ log.debug("reset_fpga: re-applying FPGA settings")
2788
+
2789
+ self.set_high_gain_mode_enable (self.settings.state.high_gain_mode_enabled)
2790
+ self.set_integration_time_ms (self.settings.state.integration_time_ms)
2791
+ self.set_detector_gain (self.settings.eeprom.detector_gain)
2792
+ self.set_detector_offset (self.settings.eeprom.detector_offset)
2793
+ self.set_detector_gain_odd (self.settings.eeprom.detector_gain_odd)
2794
+ self.set_detector_offset_odd (self.settings.eeprom.detector_offset_odd)
2795
+
2796
+ self.queue_message("marquee_info", "FPGA reset")
2797
+
2798
+ log.debug("reset_fpga: done")
2799
+ return result
2800
+
2801
+ ##
2802
+ # Read the trigger source setting from the device.
2803
+ #
2804
+ # - 0 = internal
2805
+ # - 1 = external
2806
+ #
2807
+ # Use caution when interpreting the larger behavior of
2808
+ # the device as ARM and FX2 implementations differ as of 2017-08-02
2809
+ #
2810
+ # @note never called by ENLIGHTEN - provided for OEMs
2811
+ def get_trigger_source(self):
2812
+ value = self._get_code(0xd3, label="GET_TRIGGER_SOURCE", msb_len=1)
2813
+ self.settings.state.trigger_source = value.data
2814
+ return value
2815
+
2816
+ ##
2817
+ # @note not called by ENLIGHTEN
2818
+ # @warning conflicts with GET_SELECTED_LASER
2819
+ def get_raman_mode_enabled_NOT_USED(self):
2820
+ res = self.get_upper_code(0x15, label="GET_RAMAN_MODE_ENABLED", msb_len=1)
2821
+ res.data = 0 != res.data
2822
+ return res
2823
+
2824
+ ##
2825
+ # Enable "Raman mode" (automatic laser) in the spectrometer firmware.
2826
+ def set_raman_mode_enable_NOT_USED(self, flag: bool):
2827
+ if not self.settings.is_micro():
2828
+ log.debug("Raman mode only supported on Series-XS")
2829
+ return SpectrometerResponse(data=False, error_msg="raman mode not supported")
2830
+
2831
+ return self._send_code(bRequest = 0xff,
2832
+ wValue = 0x16,
2833
+ wIndex = 1 if flag else 0,
2834
+ data_or_wLength = [0] * 8,
2835
+ label = "SET_RAMAN_MODE_ENABLE")
2836
+
2837
+ def get_raman_delay_ms(self):
2838
+ res = self.get_upper_code(0x19, label="GET_RAMAN_DELAY_MS", msb_len=2)
2839
+ self.settings.state.raman_delay_ms = res.data
2840
+ return res
2841
+
2842
+ def set_raman_delay_ms(self, ms: int):
2843
+ if not self.settings.is_micro():
2844
+ log.debug("Raman delay only supported on Series-XS")
2845
+ return SpectrometerResponse(data=False, error_msg="raman delay not supported")
2846
+
2847
+ # send value as big-endian
2848
+ msb = (ms >> 8) & 0xff
2849
+ lsb = ms & 0xff
2850
+ value = (msb << 8) | lsb
2851
+
2852
+ self.settings.state.raman_delay_ms = ms
2853
+ return self._send_code(bRequest = 0xff,
2854
+ wValue = 0x20,
2855
+ wIndex = value,
2856
+ data_or_wLength = [0] * 8,
2857
+ label = "SET_RAMAN_DELAY_MS")
2858
+
2859
+ def get_laser_watchdog_sec(self):
2860
+ res = self.get_upper_code(0x17, label="GET_LASER_WATCHDOG_SEC", msb_len=2)
2861
+ self.settings.state.laser_watchdog_sec = res.data
2862
+ return res
2863
+
2864
+ # @note until this change is mimick'd in the STM32 (and after that, until the FPGA
2865
+ # logic itself is fixed), always send a DISABLE_LASER before changing the
2866
+ # watchdog period.
2867
+ def set_laser_watchdog_sec(self, sec):
2868
+ if not self.settings.is_micro():
2869
+ log.error("Laser watchdog only supported on Series-XS")
2870
+ return SpectrometerResponse(data=False, error_msg="laser watchdog not supported")
2871
+
2872
+ # remove this call after the Series-XS ARM / FPGA watchdog are fixed
2873
+ # MZ: are they fixed? can I remove this?
2874
+ # self.set_laser_enable(False)
2875
+
2876
+ # send value as big-endian
2877
+ msb = (sec >> 8) & 0xff
2878
+ lsb = sec & 0xff
2879
+ value = (msb << 8) | lsb
2880
+
2881
+ self.settings.state.laser_watchdog_sec = sec
2882
+ return self._send_code(bRequest = 0xff,
2883
+ wValue = 0x18,
2884
+ wIndex = value,
2885
+ data_or_wLength = [0] * 8,
2886
+ label = "SET_LASER_WATCHDOG_SEC")
2887
+
2888
+ def update_laser_watchdog(self):
2889
+ """
2890
+ Automatically set the laser watchdog long enough to handle the current
2891
+ integration time, assuming we have to perform 6 throwaways on the sensor
2892
+ in case it went to sleep.
2893
+
2894
+ @note we are not currently using this function
2895
+ """
2896
+ if not self.settings.is_micro() or not self.settings.eeprom.has_laser:
2897
+ return SpectrometerResponse(data=False, error_msg="update laser watchdog not supported")
2898
+
2899
+ int_ms = self.settings.state.integration_time_ms
2900
+ scans = self.settings.state.scans_to_average
2901
+
2902
+ throwaways_sec = int_ms * (8 + scans) / 1000.0
2903
+ watchdog_sec = int(max(10, throwaways_sec)) * 2
2904
+ log.debug(f"updating laser watchdog to {watchdog_sec} based on integration time {int_ms}ms and {scans} averaging")
2905
+
2906
+ return self.set_laser_watchdog_sec(watchdog_sec)
2907
+
2908
+ def update_vertical_roi(self):
2909
+ if self.settings.is_micro():
2910
+ roi = self.settings.get_vertical_roi()
2911
+ if roi is not None:
2912
+ self.set_vertical_roi(roi)
2913
+
2914
+ def set_vertical_roi(self, roi):
2915
+ # check for legacy SiG-VIS since it didn't like vertical binning
2916
+ if self.settings.fpga_firmware_version == "000-008" and self.settings.microcontroller_firmware_version == "0.1.0.7":
2917
+ return SpectrometerResponse(data=False)
2918
+
2919
+ if not self.settings.is_micro():
2920
+ if not self.settings.supports_feature("hamamatsu_vertical_roi"):
2921
+ log.warning("Vertical Binning only configurable on XS and prototype X/XM")
2922
+ return SpectrometerResponse(data=False, error_msg="vertical binning not supported")
2923
+
2924
+ if isinstance(roi, ROI):
2925
+ start, end = roi.start, roi.end
2926
+ elif len(roi) == 2:
2927
+ start, end = roi[0], roi[1]
2928
+ else:
2929
+ log.error("set_vertical_roi: requires an ROI object or tuple of (start, stop) lines")
2930
+ return SpectrometerResponse(data=False, error_msg="invalid start and stop lines")
2931
+
2932
+ if start < 0 or end < 0:
2933
+ log.error("set_vertical_roi: requires POSITIVE (start, stop) lines")
2934
+ return SpectrometerResponse(data=False, error_msg="invalid start and stop lines")
2935
+
2936
+ # enforce ascending order (also, note that stop line is "last line binned + 1", so stop must be > start)
2937
+ if start >= end:
2938
+ # (start, end) = (end, start)
2939
+ log.error("set_vertical_roi: requires ascending order (ignoring %d, %d)", start, end)
2940
+ return SpectrometerResponse(data=False, error_msg="invalid start and stop lines")
2941
+
2942
+ if self.settings.is_xs():
2943
+ # ARM has USB opcodes for start/stop line
2944
+ self._send_code(bRequest=0xff, wValue=0x21, wIndex=start, label="SET_CMOS_START_LINE")
2945
+ self._send_code(bRequest=0xff, wValue=0x23, wIndex=end, label="SET_CMOS_STOP_LINE")
2946
+ else:
2947
+ # FX2 lacks USB opcodes for start/stop line, so use I2C Poke (clamp to 1 byte)
2948
+ start = int(max(0, min(255, start)))
2949
+ end = int(max(0, min(255, end )))
2950
+ self.i2c_write(0x29, [start], label="I2C_POKE(SET_CCD_START_LINE)")
2951
+ self.i2c_write(0x2A, [end], label="I2C_POKE(SET_CCD_STOP_LINE)")
2952
+
2953
+ self.settings.eeprom.roi_vertical_region_1_start = start
2954
+ self.settings.eeprom.roi_vertical_region_1_end = end
2955
+ self.reset_area_scan_frame()
2956
+
2957
+ return SpectrometerResponse(data=True)
2958
+
2959
+ ##
2960
+ # @param mode: integral value 0-3
2961
+ #
2962
+ # \verbatim
2963
+ # mode ADC (AD) Pixel Width (OD)
2964
+ # b00 10-bit 10-bit
2965
+ # b01 10-bit 12-bit
2966
+ # b10 12-bit 10-bit
2967
+ # b11 12-bit 12-bit
2968
+ # \endverbatim
2969
+ def set_pixel_mode(self, mode: float):
2970
+ if not self.settings.is_micro():
2971
+ log.debug("Pixel Mode only configurable on Series-XS")
2972
+ return SpectrometerResponse(data=False, error_msg="pixel mode not supported")
2973
+
2974
+ # we only care about the two least-significant bits
2975
+ mode = int(round(mode)) & 0x3
2976
+
2977
+ result = self._send_code(bRequest = 0xfd,
2978
+ wValue = mode,
2979
+ label = "SET_PIXEL_MODE")
2980
+
2981
+ log.debug("waiting 1sec...")
2982
+ sleep(1)
2983
+
2984
+ return result
2985
+
2986
+ def clear_regions(self):
2987
+ x1 = self.settings.eeprom.active_pixels_horizontal
2988
+ y1 = self.settings.eeprom.active_pixels_vertical
2989
+ log.debug(f"resettings detector to full ({x1}, {y1}) extent")
2990
+
2991
+ self.settings.state.region = None
2992
+ self.settings.update_wavecal()
2993
+
2994
+ return self.set_detector_roi([0, 0, y1, 0, x1], store=False)
2995
+
2996
+ def set_single_region(self, n: int):
2997
+ """
2998
+ This function uses the the multi-region feature to select just a single
2999
+ pre-configured region at a time. Whichever region is selected, that
3000
+ region's parameters are written to "region 0" of the spectrometer, and
3001
+ the global wavecal is updated to use that region's calibration.
3002
+
3003
+ @todo consider clear_region() function to restore physical ROI to
3004
+ (0, active_vertical_pixels, 0, active_horizontal_pixels)
3005
+ (leave wavecal alone?)
3006
+ """
3007
+ if self.settings.state.detector_regions is None:
3008
+ log.debug(f"no detector regions configured")
3009
+ return SpectrometerResponse(data=False, error_msg="no regions configured")
3010
+
3011
+ roi = self.settings.state.detector_regions.get_roi(n)
3012
+ if roi is None:
3013
+ log.debug(f"unconfigured region {n} (max {self.settings.eeprom.region_count}")
3014
+ return SpectrometerResponse(data=False, error_msg="unconfigured region")
3015
+
3016
+ log.debug(f"set_single_region: applying region {n}: {roi}")
3017
+ self.settings.set_single_region(n)
3018
+
3019
+ # send a "fake" ROI downstream, overriding to position 0
3020
+ self.set_detector_roi([0, roi.y0, roi.y1, roi.x0, roi.x1], store=False)
3021
+ return SpectrometerResponse()
3022
+
3023
+ def set_detector_roi(self, args: list[float], store: bool = True):
3024
+ """
3025
+ Note this only sends the ROI downstream to the spectrometer (and stores
3026
+ it in DetectorRegions). If you want to update the wavecal and store
3027
+ the "selected" region index, use set_region() instead (which calls this).
3028
+
3029
+ You should use set_region() if you are selecting one of the standard
3030
+ regions already configured on the EEPROM. You should use set_detector_roi()
3031
+ if you're making ad-hoc ROIs which aren't configured on the EEPROM.
3032
+
3033
+ @param args: either a DetectorROI or a tuple of (region, y0, y1, x0, x1)
3034
+ """
3035
+ if not self.settings.is_micro():
3036
+ log.debug("Detector ROI only configurable on Series-XS")
3037
+ return SpectrometerResponse(data=False, error_msg="Detector ROI not configurable")
3038
+
3039
+ if isinstance(args, DetectorROI):
3040
+ roi = args
3041
+ log.debug(f"passed DetectorROI: {roi}")
3042
+ else:
3043
+ # convert args to ROI
3044
+ log.debug(f"creating DetectorROI from args: {args}")
3045
+
3046
+ if len(args) != 5:
3047
+ log.error(f"invalid detector roi args: {args}")
3048
+ return SpectrometerResponse(data=False, error_msg="invalid roi args")
3049
+
3050
+ region = int(round(args[0]))
3051
+ y0 = int(round(args[1]))
3052
+ y1 = int(round(args[2]))
3053
+ x0 = int(round(args[3]))
3054
+ x1 = int(round(args[4]))
3055
+
3056
+ if not (0 <= region <= 3 and
3057
+ y0 < y1 and
3058
+ x0 < x1 and
3059
+ y0 >= 0 and
3060
+ x0 >= 0 and
3061
+ y1 <= self.settings.eeprom.active_pixels_horizontal and
3062
+ x1 <= self.settings.eeprom.active_pixels_horizontal):
3063
+ log.error(f"invalid detector roi: {args}")
3064
+ return SpectrometerResponse(data=False, error_msg="invalid roi args")
3065
+ roi = DetectorROI(region, y0, y1, x0, x1)
3066
+ log.debug(f"created DetectorROI: {roi}")
3067
+
3068
+ # determine previous total pixels
3069
+ self.prev_pixels = self.settings.pixels()
3070
+ log.debug(f"prev_pixels = {self.prev_pixels}")
3071
+
3072
+ if store:
3073
+ if self.settings.state.detector_regions is None:
3074
+ log.debug("creating DetectorRegions")
3075
+ self.settings.state.detector_regions = DetectorRegions()
3076
+
3077
+ # this is a no-op if it's already present and unchanged
3078
+ log.debug("saving DetectorROI in DetectorRegions")
3079
+ self.settings.state.detector_regions.add(roi)
3080
+
3081
+ log.debug(f"total_pixels now {self.settings.pixels()}")
3082
+
3083
+ buf = utils.uint16_to_little_endian([ roi.y0, roi.y1, roi.x0, roi.x1 ])
3084
+ log.debug("would send buf: %s", buf)
3085
+
3086
+ result = self._send_code(bRequest = 0xff,
3087
+ wValue = 0x25,
3088
+ wIndex = roi.region,
3089
+ data_or_wLength = buf,
3090
+ label = "SET_DETECTOR_ROI")
3091
+
3092
+ log.debug("waiting 1sec...")
3093
+ sleep(1)
3094
+
3095
+ # Just in case, flows the updated DetectorRegions object upstream
3096
+ # so caller has access to it.
3097
+ if store:
3098
+ self.queue_message("detector_regions", self.settings.state.detector_regions)
3099
+
3100
+ return result
3101
+
3102
+ def get_fpga_configuration_register(self, label: str = ""):
3103
+ raw = self._get_code(0xb3, lsb_len=2, label="GET_FPGA_CONFIGURATION_REGISTER")
3104
+ log.debug(f"FPGA Configuration Register: 0x{raw:04x} ({label})")
3105
+ return raw
3106
+
3107
+ # ##########################################################################
3108
+ #
3109
+ # Accessory Connector
3110
+ #
3111
+ # ##########################################################################
3112
+
3113
+ # ##########################################################################
3114
+ # Accessory Enable
3115
+ # ##########################################################################
3116
+
3117
+ ## @todo change opcode (conflicts with GET_DETECTOR_START_LINE)
3118
+ def set_accessory_enable(self, flag: bool):
3119
+ if not self.settings.is_gen15():
3120
+ log.debug("accessory requires Gen 1.5")
3121
+ return SpectrometerResponse(data=False, error_msg="requires gen1.5")
3122
+ value = 1 if flag else 0
3123
+ return self._send_code(bRequest = 0x22,
3124
+ wValue = value,
3125
+ wIndex = 0,
3126
+ data_or_wLength = [0] * 8,
3127
+ label = "SET_ACCESSORY_ENABLE")
3128
+
3129
+ ## @todo find out opcode
3130
+ def get_discretes_enabled(self):
3131
+ if not self.settings.is_gen15():
3132
+ log.error("accessory requires Gen 1.5")
3133
+ return SpectrometerResponse(data=False, error_msg="requires gen1.5")
3134
+ # return self._get_code(0x37, label="GET_ACCESSORY_ENABLED", msb_len=1)
3135
+
3136
+ # ##########################################################################
3137
+ # Fan
3138
+ # ##########################################################################
3139
+
3140
+ def set_fan_enable(self, flag: bool):
3141
+ if not self.settings.is_gen15():
3142
+ log.debug("fan requires Gen 1.5")
3143
+ return SpectrometerResponse(data=False, error_msg="fan requires gen15.")
3144
+ value = 1 if flag else 0
3145
+ return self._send_code(bRequest = 0x36,
3146
+ wValue = value,
3147
+ wIndex = 0,
3148
+ data_or_wLength = [0] * 8,
3149
+ label = "SET_FAN_ENABLE")
3150
+
3151
+ def get_fan_enabled(self):
3152
+ if not self.settings.is_gen15():
3153
+ log.error("fan requires Gen 1.5")
3154
+ return SpectrometerResponse(data=False, error_msg="fan requires gen1.5")
3155
+ return SpectrometerResponse(data=0 != self._get_code(0x37, label="GET_FAN_ENABLED", msb_len=1))
3156
+
3157
+ # ##########################################################################
3158
+ # Lamp
3159
+ # ##########################################################################
3160
+
3161
+ def set_lamp_enable(self, flag: bool):
3162
+ if not self.settings.is_gen15():
3163
+ log.debug("lamp requires Gen 1.5")
3164
+ return SpectrometerResponse(data=False, error_msg="lamp requires gen1.5")
3165
+ value = 1 if flag else 0
3166
+ return self._send_code(bRequest = 0x32,
3167
+ wValue = value,
3168
+ wIndex = 0,
3169
+ data_or_wLength = [0] * 8,
3170
+ label = "SET_LAMP_ENABLE")
3171
+
3172
+ def get_lamp_enabled(self):
3173
+ if not self.settings.is_gen15():
3174
+ log.error("lamp requires Gen 1.5")
3175
+ return SpectrometerResponse(data=False, error_msg="lamp requires gen1.5")
3176
+ res = self._get_code(0x33, label="GET_LAMP_ENABLED", msb_len=1)
3177
+ res.data = 0 != res.data
3178
+ return res
3179
+
3180
+ # ##########################################################################
3181
+ # Shutter
3182
+ # ##########################################################################
3183
+
3184
+ def set_shutter_enable(self, flag: bool):
3185
+ if not (self.settings.is_gen15() and self.settings.eeprom.has_shutter):
3186
+ log.debug("shutter requires Gen 1.5 and has_shutter flag")
3187
+ return SpectrometerResponse(data=False, error_msg="shutter requires gen1.5")
3188
+ value = 1 if flag else 0
3189
+ return self._send_code(bRequest = 0x30,
3190
+ wValue = value,
3191
+ wIndex = 0,
3192
+ data_or_wLength = [0] * 8,
3193
+ label = "SET_SHUTTER_ENABLE")
3194
+
3195
+ def get_shutter_enabled(self):
3196
+ if not (self.settings.is_gen15() and self.settings.eeprom.has_shutter):
3197
+ log.debug("shutter requires Gen 1.5 and has_shutter flag")
3198
+ return SpectrometerResponse(data=False, error_msg="shutter requires gen1.5")
3199
+ res = SpectrometerResponse(data=0 != self._get_code(0x31, label="GET_SHUTTER_ENABLED", msb_len=1))
3200
+ res.data = 0 != res.data
3201
+ return res
3202
+
3203
+ # ##########################################################################
3204
+ # Laser Modulation and Continuous Strobe
3205
+ # ##########################################################################
3206
+
3207
+ def set_mod_enable(self, flag: bool):
3208
+ self.settings.state.mod_enabled = flag
3209
+ value = 1 if flag else 0
3210
+ return self._send_code(0xbd, value, label="SET_MOD_ENABLE")
3211
+
3212
+ def get_mod_enabled(self):
3213
+ res = self._get_code(0xe3, label="GET_MOD_ENABLED", msb_len=1)
3214
+ if res.error_msg != '':
3215
+ return res
3216
+ flag = 0 != res.data
3217
+ self.settings.state.mod_enabled = flag
3218
+ return SpectrometerResponse(data=flag)
3219
+
3220
+ def set_mod_period_us(self, us: float):
3221
+ self.settings.state.mod_period_us = us
3222
+ (lsw, msw, buf) = self._to40bit(us)
3223
+ return self._send_code(0xc7, lsw, msw, buf, label="SET_MOD_PERIOD")
3224
+
3225
+ def get_mod_period_us(self):
3226
+ result = self._get_code(0xcb, label="GET_MOD_PERIOD", lsb_len=5)
3227
+ self.settings.state.mod_period_us = result
3228
+ return result
3229
+
3230
+ def set_mod_width_us(self, us: float):
3231
+ self.settings.state.mod_width_us = us
3232
+ (lsw, msw, buf) = self._to40bit(us)
3233
+ return self._send_code(0xdb, lsw, msw, buf, label="SET_MOD_WIDTH")
3234
+
3235
+ def get_mod_width_us(self):
3236
+ result = self._get_code(0xdc, label="GET_MOD_WIDTH", lsb_len=5)
3237
+ self.settings.state.mod_width_us = result.data
3238
+ return result
3239
+
3240
+ def set_mod_delay_us(self, us: float):
3241
+ self.settings.state.mod_delay_us = us
3242
+ (lsw, msw, buf) = self._to40bit(us)
3243
+ return self._send_code(0xc6, lsw, msw, buf, label="SET_MOD_DELAY")
3244
+
3245
+ def get_mod_delay_us(self):
3246
+ result = self._get_code(0xca, label="GET_MOD_DELAY", lsb_len=5)
3247
+ self.settings.state.mod_delay_us = result.data
3248
+ return result
3249
+
3250
+ def set_mod_duration_us_NOT_USED(self, us: float):
3251
+ self.settings.state.mod_duration_us = us
3252
+ (lsw, msw, buf) = self._to40bit(us)
3253
+ return self._send_code(0xb9, lsw, msw, buf, label="SET_MOD_DURATION")
3254
+
3255
+ def get_mod_duration_us(self):
3256
+ result = self._get_code(0xc3, label="GET_MOD_DURATION", lsb_len=5)
3257
+ self.settings.state.mod_duration_us = result.data
3258
+ return result
3259
+
3260
+ ## this is a synonym for _set_laser_enable_immediate(), but without side-effects
3261
+ def set_strobe_enable(self, flag: bool):
3262
+ value = 1 if flag else 0
3263
+ return self._send_code(0xbe, value, label="SET_STROBE_ENABLE")
3264
+
3265
+ ## a literal pass-through to get_laser_enabled()
3266
+ def get_strobe_enabled(self):
3267
+ return self.get_laser_enabled()
3268
+
3269
+ # ##########################################################################
3270
+ # Ambient Temperature
3271
+ # ##########################################################################
3272
+
3273
+ def get_ambient_temperature_degC(self):
3274
+ if self.settings.is_gen15():
3275
+ return self.get_ambient_temperature_degC_gen15()
3276
+ elif self.settings.is_xs():
3277
+ return self.get_ambient_temperature_degC_arm()
3278
+ else:
3279
+ log.debug("ambient temperature requires XS or Gen 1.5")
3280
+ return
3281
+
3282
+ def get_ambient_temperature_degC_arm(self):
3283
+ if not self.settings.is_xs():
3284
+ msg = "ambient temperature ARM requires XS"
3285
+ log.debug(msg)
3286
+ return
3287
+
3288
+ if self.settings.microcontroller_firmware_version == "1.0.2.9":
3289
+ msg = "ambient temperature ARM requires newer firmware"
3290
+ log.debug(msg)
3291
+ return
3292
+
3293
+ result = self._get_code(0xff, 0x2a, label="GET_AMBIENT_TEMPERATURE_DEGC_ARM", msb_len=1)
3294
+ if result is None or result.data is None:
3295
+ msg = f"failed to read ambient temperature"
3296
+ log.error(msg)
3297
+ return SpectrometerResponse(error_msg=msg)
3298
+ degC = result.data
3299
+
3300
+ log.debug("ambient temperature degC ARM: %s", degC)
3301
+ return SpectrometerResponse(data=degC)
3302
+
3303
+ def get_ambient_temperature_degC_gen15(self):
3304
+ """ @see https://www.nxp.com/docs/en/data-sheet/LM75B.pdf """
3305
+ if not self.settings.is_gen15():
3306
+ msg = "ambient temperature Gen1.5 requires Gen 1.5"
3307
+ log.error(msg)
3308
+ return SpectrometerResponse(error_msg=msg)
3309
+
3310
+ result = self._get_code(0x35, label="GET_AMBIENT_TEMPERATURE_GEN15", msb_len=2)
3311
+ if result is None or len(result) != 2:
3312
+ msg = f"failed to read ambient temperature"
3313
+ log.error(msg)
3314
+ return SpectrometerResponse(error_msg=msg)
3315
+ log.debug("ambient temperature raw: %s", result)
3316
+
3317
+ raw = result.data
3318
+ raw = raw >> 5
3319
+ degC = 0.125 * utils.twos_comp(raw, 11)
3320
+
3321
+ log.debug("parsed ambient temperature from raw %s to %.3f degC", result, degC)
3322
+ return SpectrometerResponse(data=degC)
3323
+
3324
+ # ##########################################################################
3325
+ # added for wasatch-shell
3326
+ # ##########################################################################
3327
+
3328
+ def get_tec_enabled(self):
3329
+ if not self.settings.eeprom.has_cooling:
3330
+ log.error("unable to control TEC: EEPROM reports no cooling")
3331
+ return SpectrometerResponse(data=False, error_msg="no cooling reported")
3332
+ res = self._get_code(0xda, label="GET_CCD_TEC_ENABLED", msb_len=1)
3333
+ res.data = 0 != res.data
3334
+ if res.error_msg != '':
3335
+ return res
3336
+ return res
3337
+
3338
+ def get_actual_frames(self):
3339
+ return self._get_code(0xe4, label="GET_ACTUAL_FRAMES", lsb_len=2)
3340
+
3341
+ def get_actual_integration_time_us(self):
3342
+ return self._get_code(0xdf, label="GET_ACTUAL_INTEGRATION_TIME_US", lsb_len=3)
3343
+
3344
+ def get_detector_offset(self):
3345
+ value = self._get_code(0xc4, label="GET_DETECTOR_OFFSET", lsb_len=2)
3346
+ self.settings.eeprom.detector_offset = value.data
3347
+ return SpectrometerResponse(data=value)
3348
+
3349
+ def get_detector_offset_odd(self):
3350
+ if not self.settings.is_ingaas():
3351
+ log.debug("GET_DETECTOR_OFFSET_ODD only supported on InGaAs")
3352
+ return SpectrometerResponse(data=self.settings.eeprom.detector_offset_odd)
3353
+
3354
+ value = self._get_code(0x9e, label="GET_DETECTOR_OFFSET_ODD", lsb_len=2)
3355
+ if value.error_msg != '':
3356
+ return value
3357
+ self.settings.eeprom.detector_offset_odd = value.data
3358
+ return SpectrometerResponse(data=value)
3359
+
3360
+ def get_ccd_sensing_threshold(self):
3361
+ return self._get_code(0xd1, label="GET_CCD_SENSING_THRESHOLD", lsb_len=2)
3362
+
3363
+ def get_ccd_threshold_sensing_mode(self):
3364
+ return self._get_code(0xcf, label="GET_CCD_THRESHOLD_SENSING_MODE", msb_len=1)
3365
+
3366
+ def get_external_trigger_output(self):
3367
+ return self._get_code(0xe1, label="GET_EXTERNAL_TRIGGER_OUTPUT", msb_len=1)
3368
+
3369
+ def set_mod_linked_to_integration(self, flag: bool):
3370
+ value = 1 if flag else 0
3371
+ return self._send_code(0xdd, value, label="SET_MOD_LINKED_TO_INTEGRATION")
3372
+
3373
+ def get_mod_linked_to_integration(self):
3374
+ res = self._get_code(0xde, label="GET_MOD_LINKED_TO_INTEGRATION", msb_len=1)
3375
+ if res.error_msg != '':
3376
+ return res
3377
+ res.data = 0 != res.data
3378
+ return res
3379
+
3380
+ def get_selected_adc(self):
3381
+ value = self._get_code(0xee, label="GET_SELECTED_ADC", msb_len=1)
3382
+ if value.error_msg != '':
3383
+ return value
3384
+ if self.settings.state.selected_adc != value.data:
3385
+ log.error("GET_SELECTED_ADC %d != state.selected_adc %d", value, self.settings.state.selected_adc)
3386
+ self.settings.state.selected_adc = value.data
3387
+ return value
3388
+
3389
+ def set_trigger_delay(self, half_us: float):
3390
+ """
3391
+ A confenurable delay from when an inbound trigger signal is
3392
+ received by the spectrometer, until the triggered acquisition actually starts.
3393
+
3394
+ Default value is 0us.
3395
+
3396
+ Unit is in 0.5 microseconds (500ns), so value of 25 would represent 12.5us.
3397
+
3398
+ Value is 24bit, so max value is 16777216 (8.388608 sec).
3399
+
3400
+ Like triggering, only currently supported on ARM.
3401
+ """
3402
+ if not self.settings.is_arm():
3403
+ log.error("SET_TRIGGER_DELAY only supported on ARM")
3404
+ return False
3405
+ lsw = half_us & 0xffff
3406
+ msb = (half_us >> 16) & 0xff
3407
+ return self._send_code(0xaa, wValue=lsw, wIndex=msb, label="SET_TRIGGER_DELAY")
3408
+
3409
+ # not tested
3410
+ def get_trigger_delay(self):
3411
+ if not self.settings.is_arm():
3412
+ msg = "GET_TRIGGER_DELAY only supported on ARM"
3413
+ log.error(msg)
3414
+ return SpectrometerResponse(error_lvl=ErrorLevel.low, error_msg=msg)
3415
+ return self._get_code(0xe4, label="GET_TRIGGER_DELAY", lsb_len=3) # not sure about LSB
3416
+
3417
+ def get_vr_continuous_ccd(self):
3418
+ res = self._get_code(0xcc, label="GET_VR_CONTINUOUS_CCD", msb_len=1)
3419
+ if res.error_msg != '':
3420
+ return res
3421
+ res.data = 0 != res.data
3422
+ return res
3423
+
3424
+ def get_vr_num_frames(self):
3425
+ return self._get_code(0xcd, label="GET_VR_NUM_FRAMES", msb_len=1)
3426
+
3427
+ def get_opt_actual_integration_time(self):
3428
+ res = self.get_upper_code(0x0b, label="GET_OPT_ACT_INT_TIME", msb_len=1)
3429
+ if res.error_msg != '':
3430
+ return res
3431
+ res.data = 0 != res.data
3432
+ return res
3433
+
3434
+ def get_opt_area_scan(self):
3435
+ res = self.get_upper_code(0x0a, label="GET_OPT_AREA_SCAN", msb_len=1)
3436
+ if res.error_msg != '':
3437
+ return res
3438
+ res.data = 0 != res.data
3439
+ return res
3440
+
3441
+ def get_opt_cf_select(self):
3442
+ res = self.get_upper_code(0x07, label="GET_OPT_CF_SELECT", msb_len=1)
3443
+ if res.error_msg != '':
3444
+ return res
3445
+ res.data = 0 != res.data
3446
+ return res
3447
+
3448
+ def get_opt_data_header_tab(self):
3449
+ return self.get_upper_code(0x06, label="GET_OPT_DATA_HEADER_TAB", msb_len=1)
3450
+
3451
+ def get_opt_horizontal_binning(self):
3452
+ return self.get_upper_code(0x0c, label="GET_OPT_HORIZONTAL_BINNING", msb_len=1)
3453
+
3454
+ def get_opt_integration_time_resolution(self):
3455
+ return self.get_upper_code(0x05, label="GET_OPT_INTEGRATION_TIME_RESOLUTION", msb_len=1)
3456
+
3457
+ def update_firmware_log(self):
3458
+ if not self.settings.state.firmware_logging_enabled or not self.settings.is_xs():
3459
+ return
3460
+
3461
+ # read all pending lines
3462
+ lines = []
3463
+ start_time = datetime.datetime.now()
3464
+ max_ms = int(max(100, self.settings.state.integration_time_ms / 2))
3465
+ while True:
3466
+ response = self._get_code(0x81, label="GET_LOG")
3467
+ if response is None:
3468
+ break
3469
+
3470
+ data = response.data
3471
+ if data is None or len(data) == 0:
3472
+ break
3473
+
3474
+ line = ""
3475
+ for c in data[1:]:
3476
+ if c == 0:
3477
+ break
3478
+ line += chr(c)
3479
+
3480
+ if 0 == len(line):
3481
+ break
3482
+
3483
+ lines.append(line)
3484
+
3485
+ if (datetime.datetime.now() - start_time).total_seconds() * 1000 > max_ms:
3486
+ log.debug("update_firmware_log: enough for now")
3487
+ break
3488
+
3489
+ if len(lines) > 0:
3490
+ for line in lines:
3491
+ log.debug(f"update_firmware_log: {line}")
3492
+ self.queue_message("firmware_log", lines)
3493
+
3494
+ # ##########################################################################
3495
+ # Analog output
3496
+ # ##########################################################################
3497
+
3498
+ ## @param value (Input) tuple of (bool enable, int mode)
3499
+ def set_analog_output_mode(self, value: tuple[bool, int]):
3500
+ if not self.settings.is_gen2():
3501
+ log.error("analog output only available on Gen2")
3502
+ return SpectrometerResponse(data=False, error_msg="analog output unsupported")
3503
+
3504
+ wIndex = 0
3505
+
3506
+ # parse enable and mode from value tuple
3507
+ try:
3508
+ if isinstance(value[0], bool):
3509
+ enable = value[0]
3510
+ else:
3511
+ enable = value[0] != 0
3512
+
3513
+ mode = int(value[1])
3514
+ if (mode != 0 and mode != 1):
3515
+ log.error("invalid analog output mode 0x%02x, disabling", mode)
3516
+ enable = False
3517
+ mode = 0
3518
+
3519
+ if enable:
3520
+ wIndex = 0x02 | mode # 0x02 sets the "enable" bit
3521
+ self.state.analog_out_enable = True
3522
+ self.state.analog_out_mode = mode
3523
+ self.state.analog_out_value = 0 if mode == 0 else 40 # 4mA default current in deci-mA
3524
+ else:
3525
+ wIndex = 0
3526
+ self.state.analog_out_enable = False
3527
+ self.state.analog_out_mode = 0
3528
+ self.state.analog_out_value = 0
3529
+
3530
+ except:
3531
+ log.error("set_analog_output_mode takes tuple of (bool, int), disabling")
3532
+ wIndex = 0
3533
+
3534
+ return self._send_code(bRequest = 0xff,
3535
+ wValue = 0x11,
3536
+ wIndex = wIndex,
3537
+ label = "SET_ANALOG_OUT_MODE")
3538
+
3539
+ def set_analog_output_value(self, value: int):
3540
+ if not self.settings.is_gen2():
3541
+ log.error("analog output only available on Gen2")
3542
+ return SpectrometerResponse(data=False, error_msg="analog output unsupported")
3543
+
3544
+ # spectrometer should range-limit, but just to codify:
3545
+ if self.state.analog_out_mode == 0:
3546
+ # voltage (decivolts, range 0-50 (0-5V))
3547
+ if value < 0:
3548
+ value = 0
3549
+ if value > 50:
3550
+ value = 50
3551
+ elif self.state.analog_out_mode == 1:
3552
+ # current (deci-mA, range 40-200 (4-20mA))
3553
+ if value < 40:
3554
+ value = 40
3555
+ elif value > 200:
3556
+ value = 200
3557
+ else:
3558
+ log.error("invalid analog out mode %d, ignoring value", self.state.analog_out_mode)
3559
+ return SpectrometerResponse(data=False, error_msg="invalid mode")
3560
+
3561
+ return self._send_code(bRequest = 0xff,
3562
+ wValue = 0x12,
3563
+ wIndex = value,
3564
+ label = "SET_ANALOG_OUT_VALUE")
3565
+
3566
+ def get_analog_output_state(self):
3567
+ if not self.settings.is_gen2():
3568
+ log.error("analog output only available on Gen2")
3569
+ return SpectrometerResponse(data=False, error_msg="analog output unsupported")
3570
+
3571
+ res = self.get_upper_code(0x1a, wLength=3, label="GET_ANALOG_OUT_STATE")
3572
+ if res.error_msg != '':
3573
+ return res
3574
+ data = res.data
3575
+ if (data is None or len(data) != 3):
3576
+ log.error("invalid analog out state read: %s", data)
3577
+ return SpectrometerResponse(data=False, error_msg="invalid analog out state read")
3578
+
3579
+ if data[0] != 0 and data[0] != 1:
3580
+ log.error("received invalid analog out enable: %d", data[0])
3581
+ if data[1] != 0 and data[1] != 1:
3582
+ log.error("received invalid analog out mode: %d", data[1])
3583
+
3584
+ self.state.analog_out_enable = data[0] != 0
3585
+ self.state.analog_out_mode = data[1]
3586
+ self.state.analog_out_value = data[2] # no range-checking applied
3587
+ data = (self.state.analog_out_enable,
3588
+ self.state.analog_out_mode,
3589
+ self.state.analog_out_value)
3590
+ return SpectrometerResponse(data=data)
3591
+
3592
+ # @returns decivolts
3593
+ def get_analog_input_value(self):
3594
+ if not self.settings.is_gen2():
3595
+ log.error("analog input only available on Gen2")
3596
+ return SpectrometerResponse(data=False, error_msg="analog input unsupported")
3597
+ return self.get_upper_code(0x1b, lsb_len=1, label="GET_ANALOG_IN_VALUE")
3598
+
3599
+ # ##########################################################################
3600
+ # EEPROM Cruft
3601
+ # ##########################################################################
3602
+
3603
+ def update_session_eeprom(self, pair: tuple[str, EEPROM]):
3604
+ """
3605
+ Given a (serial_number, EEPROM) pair, update this process's "session"
3606
+ EEPROM with just the EDITABLE fields of the passed EEPROM.
3607
+ """
3608
+ log.debug("fid.update_session_eeprom: %s updating EEPROM instance", self.settings.eeprom.serial_number)
3609
+
3610
+ if not self.eeprom_backup:
3611
+ self.eeprom_backup = copy.deepcopy(self.settings.eeprom)
3612
+
3613
+ self.settings.eeprom.update_editable(pair[1])
3614
+ return SpectrometerResponse(data=True)
3615
+
3616
+ def replace_session_eeprom(self, pair: tuple[str, EEPROM]):
3617
+ """
3618
+ Given a (serial_number, EEPROM) pair, replace this process's "session"
3619
+ EEPROM with the passed EEPROM.
3620
+ """
3621
+ log.debug("fid.replace_session_eeprom: %s replacing EEPROM instance", self.settings.eeprom.serial_number)
3622
+
3623
+ if not self.eeprom_backup:
3624
+ self.eeprom_backup = copy.deepcopy(self.settings.eeprom)
3625
+
3626
+ self.settings.eeprom = pair[1]
3627
+ self.settings.eeprom.dump()
3628
+ return SpectrometerResponse()
3629
+
3630
+ ## Actually store the current session EEPROM fields to the spectrometer.
3631
+ def write_eeprom(self):
3632
+ if not self.eeprom_backup:
3633
+ log.critical("expected to update or replace EEPROM object before write command")
3634
+ self.queue_message("marquee_error", "Failed to write EEPROM")
3635
+ return SpectrometerResponse(data=False, error_msg="failed to write eeprom")
3636
+
3637
+ # backup contents of previous EEPROM in log
3638
+ log.debug("Original EEPROM contents")
3639
+ self.eeprom_backup.dump()
3640
+ log.debug("Original EEPROM buffers: %s", self.eeprom_backup.buffers)
3641
+
3642
+ try:
3643
+ self.settings.eeprom.generate_write_buffers()
3644
+ except:
3645
+ log.critical("failed to render EEPROM write buffers", exc_info=1)
3646
+ self.queue_message("marquee_error", "Failed to write EEPROM")
3647
+ return SpectrometerResponse(data=False, error_msg="failed to generate eeprom")
3648
+
3649
+ log.debug("Would write new buffers: %s", self.settings.eeprom.write_buffers)
3650
+
3651
+ for page in range(EEPROM.MAX_PAGES):
3652
+ if self.settings.is_arm():
3653
+ log.debug("writing page %d: %s", page, self.settings.eeprom.write_buffers[page])
3654
+ self._send_code(bRequest = 0xff, # second-tier
3655
+ wValue = 0x02,
3656
+ wIndex = page,
3657
+ data_or_wLength = self.settings.eeprom.write_buffers[page],
3658
+ label = "WRITE_EEPROM")
3659
+ else:
3660
+ DATA_START = 0x3c00
3661
+ offset = DATA_START + page * 64
3662
+ log.debug("writing page %d at offset 0x%04x: %s", page, offset, self.settings.eeprom.write_buffers[page])
3663
+ self._send_code(bRequest = 0xa2, # dangerous
3664
+ wValue = offset, # arguably an index but hey
3665
+ wIndex = 0,
3666
+ data_or_wLength = self.settings.eeprom.write_buffers[page],
3667
+ label = "WRITE_EEPROM")
3668
+
3669
+ self.queue_message("marquee_info", "EEPROM successfully updated")
3670
+
3671
+ # any value in doing this?
3672
+ self.settings.eeprom.buffers = self.settings.eeprom.write_buffers
3673
+
3674
+ return SpectrometerResponse(data=True)
3675
+
3676
+ # ##########################################################################
3677
+ # Intraprocess Communications
3678
+ # ##########################################################################
3679
+
3680
+ # @todo move string-to-enum converter to AppLog
3681
+ def set_log_level(self, s: str):
3682
+ lvl = logging.DEBUG if s == "DEBUG" else logging.INFO
3683
+ log.debug("fid.set_log_level: setting to %s", lvl)
3684
+ logging.getLogger().setLevel(lvl)
3685
+ return SpectrometerResponse()
3686
+
3687
+ def queue_message(self, setting, value):
3688
+ """
3689
+ If an upstream queue is defined, send the name-value pair. Does nothing
3690
+ if the caller hasn't provided a queue.
3691
+
3692
+ "setting" is application (caller) dependent, but ENLIGHTEN currently uses
3693
+ "marquee_info" and "marquee_error".
3694
+ """
3695
+ if self.message_queue is None:
3696
+ return SpectrometerResponse(data=False)
3697
+
3698
+ msg = StatusMessage(setting, value)
3699
+ try:
3700
+ self.message_queue.put(msg)
3701
+ except:
3702
+ log.error("failed to enqueue StatusMessage (%s, %s)", setting, value, exc_info=1)
3703
+ return SpectrometerResponse(data=False, error_msg="failed to enqueue messsage")
3704
+ return SpectrometerResponse(data=True)
3705
+
3706
+ def check_alert(self, s):
3707
+ log.debug(f"checking for alert {s}")
3708
+ self.refresh_alerts()
3709
+ if s in self.alerts:
3710
+ log.debug(f"found {s} (clearing)")
3711
+ self.alerts.remove(s)
3712
+ return True
3713
+
3714
+ def refresh_alerts(self):
3715
+ if self.alert_queue is None:
3716
+ return
3717
+
3718
+ if self.alert_queue.empty():
3719
+ return
3720
+
3721
+ while not self.alert_queue.empty():
3722
+ alert = self.alert_queue.get_nowait()
3723
+ if alert is None:
3724
+ continue
3725
+ elif isinstance(alert, ControlObject):
3726
+ if alert.value:
3727
+ log.debug(f"raised alert {alert.setting}")
3728
+ self.alerts.add(alert.setting)
3729
+ else:
3730
+ log.debug(f"cleared alert {alert.setting}")
3731
+ self.alerts.discard(alert.setting)
3732
+ else:
3733
+ log.error(f"non-ControlObject found in alerts_queue: {alert}")
3734
+
3735
+ def _init_process_funcs(self):
3736
+ """
3737
+ Is it the expectation that all of these functions will return
3738
+ SpectrometerResponse? If so, that should be made explicit.
3739
+ """
3740
+ process_f = {}
3741
+
3742
+ for fn_name in [
3743
+ "can_laser_fire",
3744
+ "clear_regions",
3745
+ "connect",
3746
+ "disconnect",
3747
+ "get_actual_frames",
3748
+ "get_actual_integration_time_us",
3749
+ "get_ambient_temperature_degC",
3750
+ "get_analog_input_value",
3751
+ "get_analog_output_state",
3752
+ "get_battery_charging",
3753
+ "get_battery_percentage",
3754
+ "get_battery_register",
3755
+ "get_battery_state_raw",
3756
+ "get_ble_firmware_version",
3757
+ "get_ccd_sensing_threshold",
3758
+ "get_ccd_threshold_sensing_mode",
3759
+ "get_dac",
3760
+ "get_detector_gain",
3761
+ "get_detector_gain_odd",
3762
+ "get_detector_offset",
3763
+ "get_detector_offset_odd",
3764
+ "get_detector_tec_setpoint_degC",
3765
+ "get_detector_tec_setpoint_raw",
3766
+ "get_detector_temperature_degC",
3767
+ "get_detector_temperature_raw",
3768
+ "get_discretes_enabled",
3769
+ "get_external_trigger_output",
3770
+ "get_fan_enabled",
3771
+ "get_fpga_configuration_register",
3772
+ "get_fpga_firmware_version",
3773
+ "get_high_gain_mode_enabled",
3774
+ "get_integration_time_ms",
3775
+ "get_lamp_enabled",
3776
+ "get_laser_enabled",
3777
+ "get_laser_interlock",
3778
+ "get_laser_tec_mode",
3779
+ "get_laser_temperature_degC",
3780
+ "get_laser_temperature_raw",
3781
+ "get_laser_temperature_setpoint_raw",
3782
+ "get_laser_warning_delay_sec",
3783
+ "get_laser_watchdog_sec",
3784
+ "get_line",
3785
+ "get_spectrum",
3786
+ "get_microcontroller_firmware_version",
3787
+ "get_mod_delay_us",
3788
+ "get_mod_duration_us",
3789
+ "get_mod_enabled",
3790
+ "get_mod_period_us",
3791
+ "get_mod_width_us",
3792
+ "get_opt_actual_integration_time",
3793
+ "get_opt_area_scan",
3794
+ "get_opt_cf_select",
3795
+ "get_opt_data_header_tab",
3796
+ "get_opt_has_laser",
3797
+ "get_opt_horizontal_binning",
3798
+ "get_opt_integration_time_resolution",
3799
+ "get_opt_laser_control",
3800
+ "get_raman_delay_ms",
3801
+ "get_secondary_adc_calibrated",
3802
+ "get_secondary_adc_raw",
3803
+ "get_selected_adc",
3804
+ "get_selected_laser",
3805
+ "get_sensor_line_length",
3806
+ "get_shutter_enabled",
3807
+ "get_strobe_enabled",
3808
+ "get_tec_enabled",
3809
+ "get_trigger_delay",
3810
+ "get_trigger_source",
3811
+ "get_vr_continuous_ccd",
3812
+ "get_vr_num_frames",
3813
+ "is_laser_firing",
3814
+ "queue_message",
3815
+ "replace_session_eeprom",
3816
+ "reset_fpga",
3817
+ "select_adc",
3818
+ "set_accessory_enable",
3819
+ "set_analog_output_mode",
3820
+ "set_analog_output_value",
3821
+ "set_area_scan_enable",
3822
+ "set_detector_gain",
3823
+ "set_detector_gain_odd",
3824
+ "set_detector_offset",
3825
+ "set_detector_offset_odd",
3826
+ "set_detector_roi",
3827
+ "set_detector_tec_setpoint_degC",
3828
+ "set_dfu_enable",
3829
+ "set_fan_enable",
3830
+ "set_high_gain_mode_enable",
3831
+ "set_integration_time_ms",
3832
+ "set_lamp_enable",
3833
+ "set_laser_enable",
3834
+ "set_laser_power_high_resolution",
3835
+ "set_laser_power_mW",
3836
+ "set_laser_power_perc",
3837
+ "set_laser_power_perc_immediate",
3838
+ "set_laser_power_require_modulation",
3839
+ "set_laser_tec_mode",
3840
+ "set_laser_temperature_setpoint_raw",
3841
+ "set_laser_watchdog_sec",
3842
+ "set_log_level",
3843
+ "set_mod_delay_us",
3844
+ "set_mod_enable",
3845
+ "set_mod_linked_to_integration",
3846
+ "set_mod_period_us",
3847
+ "set_mod_width_us",
3848
+ "set_pixel_mode",
3849
+ "set_raman_delay_ms",
3850
+ "set_selected_laser",
3851
+ "set_shutter_enable",
3852
+ "set_single_region",
3853
+ "set_strobe_enable",
3854
+ "set_tec_enable",
3855
+ "set_trigger_delay",
3856
+ "set_trigger_source",
3857
+ "set_vertical_roi",
3858
+ "update_laser_watchdog",
3859
+ "update_session_eeprom",
3860
+ "write_eeprom",
3861
+ ]:
3862
+ process_f[fn_name] = getattr(self, fn_name)
3863
+
3864
+ ##################################################################
3865
+ # What follows is the old init-lambdas that are squashed into process_f
3866
+ # Long term, the upstream requests should be changed to match the new format
3867
+ # This is an easy fix for the time being to make things behave
3868
+ ##################################################################
3869
+ # spectrometer control
3870
+ process_f["laser_enable"] = lambda x: self.set_laser_enable(bool(x))
3871
+ process_f["integration_time_ms"] = lambda x: self.set_integration_time_ms(x)
3872
+
3873
+ process_f["detector_tec_setpoint_degC"] = lambda x: self.set_detector_tec_setpoint_degC(int(round(x)))
3874
+ process_f["detector_tec_enable"] = lambda x: self.set_tec_enable(bool(x))
3875
+ process_f["detector_gain"] = lambda x: self.set_detector_gain(float(x))
3876
+ process_f["detector_offset"] = lambda x: self.set_detector_offset(int(round(x)))
3877
+ process_f["detector_gain_odd"] = lambda x: self.set_detector_gain_odd(float(x))
3878
+ process_f["detector_offset_odd"] = lambda x: self.set_detector_offset_odd(int(round(x)))
3879
+ process_f["degC_to_dac_coeffs"] = lambda x: self.settings.eeprom.set("degC_to_dac_coeffs", x)
3880
+
3881
+ process_f["laser_power_perc"] = lambda x: self.set_laser_power_perc(x)
3882
+ process_f["laser_power_mW"] = lambda x: self.set_laser_power_mW(x)
3883
+ process_f["laser_temperature_setpoint_raw"] = lambda x: self.set_laser_temperature_setpoint_raw(int(round(x)))
3884
+ process_f["laser_power_attenuator"] = lambda x: self.set_laser_power_attenuator(int(round(x)))
3885
+ process_f["laser_power_high_resolution"] = lambda x: self.set_laser_power_high_resolution(x)
3886
+ process_f["laser_power_require_modulation"] = lambda x: self.set_laser_power_require_modulation(x)
3887
+ process_f["selected_laser"] = lambda x: self.set_selected_laser(int(x))
3888
+ process_f["set_laser_warning_delay_sec"] = lambda x: self.set_laser_warning_delay_sec(int(x))
3889
+
3890
+ process_f["high_gain_mode_enable"] = lambda x: self.set_high_gain_mode_enable(bool(x))
3891
+ process_f["trigger_source"] = lambda x: self.set_trigger_source(int(x))
3892
+ process_f["enable_secondary_adc"] = lambda x: self.settings.state.set("secondary_adc_enabled", bool(x))
3893
+ process_f["area_scan_enable"] = lambda x: self.set_area_scan_enable(bool(x))
3894
+ process_f["area_scan_line_step"] = lambda x: self.set_area_scan_line_step(int(x))
3895
+ process_f["area_scan_fast"] = lambda x: self.settings.state.set("area_scan_fast", bool(x))
3896
+
3897
+ process_f["bad_pixel_mode"] = lambda x: self.settings.state.set("bad_pixel_mode", int(x))
3898
+ process_f["min_usb_interval_ms"] = lambda x: self.settings.state.set("min_usb_interval_ms", int(round(x)))
3899
+ process_f["max_usb_interval_ms"] = lambda x: self.settings.state.set("max_usb_interval_ms", int(round(x)))
3900
+
3901
+ process_f["accessory_enable"] = lambda x: self.set_accessory_enable(bool(x))
3902
+ process_f["fan_enable"] = lambda x: self.set_fan_enable(bool(x))
3903
+ process_f["lamp_enable"] = lambda x: self.set_lamp_enable(bool(x))
3904
+ process_f["shutter_enable"] = lambda x: self.set_shutter_enable(bool(x))
3905
+ process_f["strobe_enable"] = lambda x: self.set_strobe_enable(bool(x))
3906
+ process_f["mod_enable"] = lambda x: self.set_mod_enable(bool(x))
3907
+ process_f["mod_period_us"] = lambda x: self.set_mod_period_us(int(round(x)))
3908
+ process_f["mod_width_us"] = lambda x: self.set_mod_width_us(int(round(x)))
3909
+
3910
+ # BatchCollection
3911
+ process_f["take_one_request"] = lambda x: self.settings.state.set("take_one_request", x)
3912
+
3913
+ # Series-XS
3914
+ #f["raman_mode_enable"] = lambda x: self.set_raman_mode_enable(bool(x))
3915
+ process_f["raman_delay_ms"] = lambda x: self.set_raman_delay_ms(int(round(x)))
3916
+ process_f["laser_watchdog_sec"] = lambda x: self.set_laser_watchdog_sec(int(round(x)))
3917
+
3918
+ # regions
3919
+ process_f["vertical_binning"] = lambda x: self.set_vertical_roi(x) # legacy alias
3920
+ process_f["vertical_roi"] = lambda x: self.set_vertical_roi(x)
3921
+ process_f["update_vertical_roi"] = lambda x: self.update_vertical_roi()
3922
+ process_f["single_region"] = lambda x: self.set_single_region(int(round(x)))
3923
+ process_f["clear_regions"] = lambda x: self.clear_regions()
3924
+ process_f["detector_roi"] = lambda x: self.set_detector_roi(x)
3925
+ process_f["pixel_mode"] = lambda x: self.set_pixel_mode(x)
3926
+
3927
+ # EEPROM updates
3928
+ process_f["update_eeprom"] = lambda x: self.update_session_eeprom(x)
3929
+ process_f["replace_eeprom"] = lambda x: self.replace_session_eeprom(x)
3930
+ process_f["write_eeprom"] = lambda x: self.write_eeprom()
3931
+
3932
+ # manufacturing
3933
+ process_f["reset_fpga"] = lambda x: self.reset_fpga()
3934
+ process_f["dfu_enable"] = lambda x: self.set_dfu_enable()
3935
+
3936
+ # legacy
3937
+ process_f["allow_default_gain_reset"] = lambda x: setattr(self, "allow_default_gain_reset", bool(x))
3938
+
3939
+ # experimental (R&D)
3940
+ #
3941
+ # MZ: note that anything calling self.settings.state.set() can probably
3942
+ # now be simplified as ENLIGHTEN has access to the same SpectrometerState object
3943
+ # (see firmware_logging_enabled)
3944
+ process_f["graph_alternating_pixels"] = lambda x: self.settings.state.set("graph_alternating_pixels", bool(x))
3945
+ process_f["swap_alternating_pixels"] = lambda x: self.settings.state.set("swap_alternating_pixels", bool(x))
3946
+ process_f["edc_enable"] = lambda x: self.settings.state.set("edc_enabled", bool(x))
3947
+ process_f["invert_x_axis"] = lambda x: self.settings.eeprom.set("invert_x_axis", bool(x))
3948
+ process_f["horiz_binning_enable"] = lambda x: self.settings.eeprom.set("horiz_binning_enabled", bool(x))
3949
+ process_f["wavenumber_correction"] = lambda x: self.settings.set_wavenumber_correction(float(x))
3950
+ process_f["linear_pixel_calibration"] = lambda x: self.settings.set_linear_pixel_calibration(x)
3951
+ process_f["onboard_scans_to_average"] = lambda x: self.set_onboard_scans_to_average(int(x))
3952
+
3953
+ # heartbeats & connection data
3954
+ process_f["raise_exceptions"] = lambda x: setattr(self, "raise_exceptions", bool(x))
3955
+ process_f["log_level"] = lambda x: self.set_log_level(x)
3956
+ process_f["num_connected_devices"] = lambda x: self.settings.set_num_connected_devices(int(x))
3957
+ process_f["subprocess_timeout_sec"] = lambda x: None
3958
+ process_f["heartbeat"] = lambda x: None
3959
+ process_f["reset"] = self.reset
3960
+
3961
+ return process_f