wasatch 2.3.8__tar.gz → 2.3.12__tar.gz

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 (94) hide show
  1. {wasatch-2.3.8 → wasatch-2.3.12}/.gitignore +1 -0
  2. {wasatch-2.3.8 → wasatch-2.3.12}/CHANGELOG.md +11 -0
  3. {wasatch-2.3.8 → wasatch-2.3.12}/PKG-INFO +1 -1
  4. {wasatch-2.3.8 → wasatch-2.3.12}/README_PYPI.md +6 -0
  5. {wasatch-2.3.8 → wasatch-2.3.12}/demo-ids-raman.py +16 -0
  6. {wasatch-2.3.8 → wasatch-2.3.12}/pyproject.toml +1 -1
  7. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/EEPROM.py +72 -12
  8. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/FeatureIdentificationDevice.py +8 -8
  9. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/IDSCamera.py +85 -27
  10. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/IDSDevice.py +54 -37
  11. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/__init__.py +1 -1
  12. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/utils.py +21 -5
  13. {wasatch-2.3.8 → wasatch-2.3.12}/.example_pypirc +0 -0
  14. {wasatch-2.3.8 → wasatch-2.3.12}/.flake8 +0 -0
  15. {wasatch-2.3.8 → wasatch-2.3.12}/.pylintrc +0 -0
  16. {wasatch-2.3.8 → wasatch-2.3.12}/Doxyfile +0 -0
  17. {wasatch-2.3.8 → wasatch-2.3.12}/LICENSE +0 -0
  18. {wasatch-2.3.8 → wasatch-2.3.12}/MANIFEST.in +0 -0
  19. {wasatch-2.3.8 → wasatch-2.3.12}/Makefile +0 -0
  20. {wasatch-2.3.8 → wasatch-2.3.12}/README.md +0 -0
  21. {wasatch-2.3.8 → wasatch-2.3.12}/README_RPI.md +0 -0
  22. {wasatch-2.3.8 → wasatch-2.3.12}/README_SETTINGS.md +0 -0
  23. {wasatch-2.3.8 → wasatch-2.3.12}/README_VIRTUAL.md +0 -0
  24. {wasatch-2.3.8 → wasatch-2.3.12}/WasatchShell/README.md +0 -0
  25. {wasatch-2.3.8 → wasatch-2.3.12}/WasatchShell/laser-test.py +0 -0
  26. {wasatch-2.3.8 → wasatch-2.3.12}/WasatchShell/load-test.py +0 -0
  27. {wasatch-2.3.8 → wasatch-2.3.12}/WasatchShell/one-shot.py +0 -0
  28. {wasatch-2.3.8 → wasatch-2.3.12}/WasatchShell/wasatch-shell.py +0 -0
  29. {wasatch-2.3.8 → wasatch-2.3.12}/demo-auto-raman.py +0 -0
  30. {wasatch-2.3.8 → wasatch-2.3.12}/demo-simple.py +0 -0
  31. {wasatch-2.3.8 → wasatch-2.3.12}/demo-virtual.py +0 -0
  32. {wasatch-2.3.8 → wasatch-2.3.12}/demo-workflow.py +0 -0
  33. {wasatch-2.3.8 → wasatch-2.3.12}/demo.py +0 -0
  34. {wasatch-2.3.8 → wasatch-2.3.12}/environments/conda-linux.yml +0 -0
  35. {wasatch-2.3.8 → wasatch-2.3.12}/environments/conda-macos.yml +0 -0
  36. {wasatch-2.3.8 → wasatch-2.3.12}/environments/conda-rpi.yml +0 -0
  37. {wasatch-2.3.8 → wasatch-2.3.12}/environments/conda-win10.yml +0 -0
  38. {wasatch-2.3.8 → wasatch-2.3.12}/screenshots/multiplatform.png +0 -0
  39. {wasatch-2.3.8 → wasatch-2.3.12}/scripts/dark-vs-integ.py +0 -0
  40. {wasatch-2.3.8 → wasatch-2.3.12}/scripts/deploy +0 -0
  41. {wasatch-2.3.8 → wasatch-2.3.12}/testSpectrometers/SiG_785/eeprom/EEPROM-EM-9c65d19f4c.json +0 -0
  42. {wasatch-2.3.8 → wasatch-2.3.12}/testSpectrometers/SiG_785/readings/enlighten-20210329-094722-799520-WP-00686.csv +0 -0
  43. {wasatch-2.3.8 → wasatch-2.3.12}/testSpectrometers/SiG_785/readings/enlighten-20210329-094755-288958-WP-00686.csv +0 -0
  44. {wasatch-2.3.8 → wasatch-2.3.12}/testSpectrometers/WP-00887/eeprom/WP-00887-mock.json +0 -0
  45. {wasatch-2.3.8 → wasatch-2.3.12}/testSpectrometers/WP-00904/eeprom/WP-00904-mock.json +0 -0
  46. {wasatch-2.3.8 → wasatch-2.3.12}/testSpectrometers/WP-00913/eeprom/WP-00913-mock.json +0 -0
  47. {wasatch-2.3.8 → wasatch-2.3.12}/tests/existing_implementations.py +0 -0
  48. {wasatch-2.3.8 → wasatch-2.3.12}/tests/noise-by-setpoint.py +0 -0
  49. {wasatch-2.3.8 → wasatch-2.3.12}/tests/queues.py +0 -0
  50. {wasatch-2.3.8 → wasatch-2.3.12}/tests/unit.py +0 -0
  51. {wasatch-2.3.8 → wasatch-2.3.12}/udev/10-wasatch.rules +0 -0
  52. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/AbstractUSBDevice.py +0 -0
  53. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/AndorDevice.py +0 -0
  54. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/AreaScanImage.py +0 -0
  55. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/AutoRaman.py +0 -0
  56. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/AutoRamanRequest.py +0 -0
  57. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/BLEDevice.py +0 -0
  58. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/BalanceAcquisition.py +0 -0
  59. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/CSVLoader.py +0 -0
  60. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/CommandSettings.py +0 -0
  61. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/ControlObject.py +0 -0
  62. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/DetectorROI.py +0 -0
  63. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/DetectorRegions.py +0 -0
  64. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/DeviceFinderBLE.py +0 -0
  65. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/DeviceFinderTCP.py +0 -0
  66. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/DeviceFinderUSB.py +0 -0
  67. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/DeviceID.py +0 -0
  68. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/EEPROM_multical.sav +0 -0
  69. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/FPGAOptions.py +0 -0
  70. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/FirmwareRequirements.py +0 -0
  71. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/HardwareInfo.py +0 -0
  72. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/IMX385.py +0 -0
  73. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/InterfaceDevice.py +0 -0
  74. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/MockUSBDevice.py +0 -0
  75. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/OceanDevice.py +0 -0
  76. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/PollStatus.py +0 -0
  77. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/ProcessedReading.py +0 -0
  78. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/ROI.py +0 -0
  79. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/Reading.py +0 -0
  80. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/RealUSBDevice.py +0 -0
  81. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/SPIDevice.py +0 -0
  82. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/SpectrometerRequest.py +0 -0
  83. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/SpectrometerResponse.py +0 -0
  84. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/SpectrometerSettings.py +0 -0
  85. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/SpectrometerState.py +0 -0
  86. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/StatusMessage.py +0 -0
  87. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/TCPDevice.py +0 -0
  88. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/TakeOneRequest.py +0 -0
  89. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/WasatchBus.py +0 -0
  90. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/WasatchDevice.py +0 -0
  91. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/WasatchDeviceWrapper.py +0 -0
  92. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/WrapperWorker.py +0 -0
  93. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/applog.py +0 -0
  94. {wasatch-2.3.8 → wasatch-2.3.12}/wasatch/simulation_protocol.py +0 -0
@@ -111,3 +111,4 @@ wasatch_applog.txt
111
111
  .DS_Store
112
112
  .vs/*
113
113
  environment.yml
114
+ data/
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ - 2026-02-17 2.3.12
4
+ - added laser_password, startup_scans_to_average, laser_attenuator
5
+ - added [un]pack_field
6
+ - un-rotated Hamamatsu area scan 180 deg :-(
7
+ - allow IDS config files to be located by serial number prefix
8
+ - 2026-02-09 2.3.11
9
+ - rotate Hamamatsu area scan 180 deg
10
+ - 2026-01-16 2.3.10
11
+ - support 2min integrations on STARVIS
12
+ - 2026-01-15 2.3.9
13
+ - added --normalize to demo-ids-raman.py
3
14
  - 2026-01-12 2.3.8
4
15
  - added settings.has_detector
5
16
  - improved support for Andor .json "virtual EEPROMs"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wasatch
3
- Version: 2.3.8
3
+ Version: 2.3.12
4
4
  Summary: Application-level driver for Wasatch Photonics spectrometers
5
5
  Requires-Python: >=3.9
6
6
  Description-Content-Type: text/markdown
@@ -50,6 +50,12 @@ before uploading to the "public, global, default" pypi repository.
50
50
  When you are ready to publish run `flit publish` or `flit publish --repository pypi`
51
51
  (or just "make publish").
52
52
 
53
+ ## Common Errors
54
+
55
+ ### requests.exceptions.HTTPError: 400 Client Error: Bad Request for url: https://test.pypi.org/legacy/
56
+
57
+ Ensure wasatch/__init__.py version matches pyproject.toml.
58
+
53
59
  # Installing 'wasatch' package from source
54
60
 
55
61
  If you've cloned the Wasatch.PY repo and want to just install the `wasatch`
@@ -26,6 +26,7 @@ import argparse
26
26
 
27
27
  from datetime import datetime
28
28
  from time import sleep
29
+ from PIL import Image, ImageStat # for image normalization
29
30
 
30
31
  import wasatch
31
32
  from wasatch import applog
@@ -59,6 +60,7 @@ class WasatchDemo:
59
60
  parser.add_argument("--save-spectra", action="store_true", help="save vertically-binned spectra to row-ordered CSV")
60
61
  parser.add_argument("--save-png", action="store_true", help="save PNG of each image frame")
61
62
  parser.add_argument("--save-data", action="store_true", help="save 2D array of each image frame")
63
+ parser.add_argument("--normalize", action="store_true", help="normalize PNG image")
62
64
  parser.add_argument("--save-dir", type=str, default=".", help="directory to save files")
63
65
  parser.add_argument("--prefix", type=str, default="ids-raman", help="filename prefix")
64
66
 
@@ -150,6 +152,9 @@ class WasatchDemo:
150
152
  ############################################################################
151
153
 
152
154
  def run(self):
155
+ if self.args.save_dir != ".":
156
+ os.makedirs(self.args.save_dir, exist_ok=True)
157
+
153
158
  self.camera_device.set_integration_time_ms(self.args.integration_time_ms)
154
159
 
155
160
  # initialize outfile if saving spectra
@@ -229,6 +234,17 @@ class WasatchDemo:
229
234
  os.replace(asi.pathname_png, filename)
230
235
  print(f"\tsaved {filename}")
231
236
 
237
+ # normalize PNG (just the image file, not the raw 2D data)
238
+ if self.args.normalize:
239
+ img = Image.open(filename).convert('L')
240
+ stat = ImageStat.Stat(img)
241
+ mean_brightness = stat.mean[0]
242
+ img_array = np.array(img)
243
+ normalized_img_array = img_array / mean_brightness * 100
244
+ normalized_img = Image.fromarray(np.uint8(normalized_img_array))
245
+ normalized_img.save(filename)
246
+ print(f"\tnormalized {filename}")
247
+
232
248
  # save area scan data
233
249
  if self.args.save_data and asi.data is not None:
234
250
  filename = os.path.join(self.args.save_dir, f"{self.args.prefix}-{self.reading_count:04d}.csv")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "wasatch"
3
- version = "2.3.8"
3
+ version = "2.3.12"
4
4
  description= "Application-level driver for Wasatch Photonics spectrometers"
5
5
  requires-python = ">=3.9"
6
6
  readme = "README.md"
@@ -25,9 +25,9 @@ log = logging.getLogger(__name__)
25
25
  # @see 24LC128 for FX2 (16KB, https://www.microchip.com/en-us/product/24LC128)
26
26
  class EEPROM:
27
27
 
28
- LATEST_REV = 17
28
+ LATEST_REV = 18
29
29
 
30
- MAX_PAGES = 8
30
+ MAX_PAGES = 9
31
31
  PAGE_LENGTH = 64
32
32
  SUBPAGE_COUNT = 4 # used for BLE
33
33
  RAMAN_INTENSITY_CALIBRATION_ORDER = 5
@@ -68,6 +68,7 @@ class EEPROM:
68
68
  ((1, 46, 2), "h", "tec_beta"),
69
69
  ((1, 48, 12), "s", "calibration_date"),
70
70
  ((1, 60, 3), "s", "calibrated_by"),
71
+
71
72
  ((2, 0, 16), "s", "detector"),
72
73
  ((2, 16, 2), "H", "active_pixels_horizontal"),
73
74
  ((2, 18, 1), "B", "laser_warmup_sec"),
@@ -87,6 +88,7 @@ class EEPROM:
87
88
  ((2, 51, 4), "f", "linearity_c2"),
88
89
  ((2, 55, 4), "f", "linearity_c3"),
89
90
  ((2, 59, 4), "f", "linearity_c4"),
91
+
90
92
  ((3, 12, 4), "f", "laser_power_c0"),
91
93
  ((3, 16, 4), "f", "laser_power_c1"),
92
94
  ((3, 20, 4), "f", "laser_power_c2"),
@@ -101,9 +103,15 @@ class EEPROM:
101
103
  ((3, 55, 2), "H", "power_timeout_sec"),
102
104
  ((3, 57, 2), "H", "detector_timeout_sec"),
103
105
  ((3, 59, 1), "B", "horiz_binning_mode"),
106
+ ((3, 60, 1), "B", "startup_scans_to_average"),
107
+ ((3, 61, 1), "B", "laser_attenuator"),
108
+
104
109
  ((4, 0, 64), "s", "user_data"),
110
+
105
111
  ((5, 30, 16), "s", "product_configuration"),
106
- ((5, 63, 1), "B", "subformat")
112
+ ((5, 63, 1), "B", "subformat"),
113
+
114
+ ((8, 0, 15), "s", "laser_password"),
107
115
  ]
108
116
 
109
117
  def __init__(self):
@@ -145,6 +153,8 @@ class EEPROM:
145
153
  self.power_timeout_sec = 0
146
154
  self.detector_timeout_sec = 0
147
155
  self.horiz_binning_mode = 0
156
+ self.startup_scans_to_average = 1
157
+ self.laser_attenuator = 127
148
158
 
149
159
  self.wavelength_coeffs = []
150
160
  self.degC_to_dac_coeffs = []
@@ -185,6 +195,8 @@ class EEPROM:
185
195
  self.bad_pixels = [] # should be set, not list (but this works with EEPROMEditor)
186
196
  self.product_configuration = None
187
197
 
198
+ self.laser_password = None
199
+
188
200
  self.format = EEPROM.LATEST_REV
189
201
  self.subformat = 0 # determines format of pages 6-7
190
202
 
@@ -227,6 +239,7 @@ class EEPROM:
227
239
  "roi_vertical_region_3_start",
228
240
  "user_text",
229
241
  "wavelength_coeffs",
242
+ "laser_password",
230
243
  ]
231
244
 
232
245
  self.init_raman_intensity_calibration()
@@ -276,7 +289,7 @@ class EEPROM:
276
289
  # ##########################################################################
277
290
 
278
291
  ##
279
- # given a set of the 8 buffers read from a spectrometer via USB,
292
+ # given a set of the 8+ buffers read from a spectrometer via USB,
280
293
  # parse those into the approrpriate fields and datatypes
281
294
  def parse(self, buffers):
282
295
  if len(buffers) < EEPROM.MAX_PAGES:
@@ -448,6 +461,10 @@ class EEPROM:
448
461
  self.detector_timeout_sec = self.unpack((3, 57, 2), "H", "detector_timeout_sec")
449
462
  self.horiz_binning_mode = self.unpack((3, 59, 1), "B", "horiz_binning_mode")
450
463
 
464
+ if self.format >= 18:
465
+ self.unpack_field("startup_scans_to_average")
466
+ self.unpack_field("laser_attenuator")
467
+
451
468
  # ######################################################################
452
469
  # Page 4
453
470
  # ######################################################################
@@ -525,6 +542,17 @@ class EEPROM:
525
542
  self.laser_timeout_after_count = False
526
543
  self.is_oem = False
527
544
 
545
+ # ######################################################################
546
+ # Page 8
547
+ # ######################################################################
548
+
549
+ if "XS" in self.model.upper(): # and self.format >= 18:
550
+
551
+ s = self.unpack_field("laser_password", quiet=True)
552
+ if len(s) < 4 or any([c in [0x00, 0xff] for c in s]):
553
+ log.debug("no laser password found")
554
+ self.laser_password = None
555
+
528
556
  # ######################################################################
529
557
  # sanity checks
530
558
  # ######################################################################
@@ -695,6 +723,8 @@ class EEPROM:
695
723
  self.pack((3, 55, 2), "H", self.power_timeout_sec)
696
724
  self.pack((3, 57, 2), "H", self.detector_timeout_sec)
697
725
  self.pack((3, 59, 1), "B", self.multi_wavelength_calibration.get("horiz_binning_mode"))
726
+ self.pack_field("startup_scans_to_average")
727
+ self.pack_field("laser_attenuator")
698
728
 
699
729
  # ######################################################################
700
730
  # Page 4
@@ -742,6 +772,14 @@ class EEPROM:
742
772
  else:
743
773
  log.error(f"Unwriteable EEPROM subformat {self.subformat}")
744
774
 
775
+ # ######################################################################
776
+ # Page 8
777
+ # ######################################################################
778
+
779
+ if "XS" in self.model.upper():
780
+ # self.pack((8, 0, 16), "s", self.laser_password)
781
+ self.pack_field("laser_password", quiet=True)
782
+
745
783
  # ##########################################################################
746
784
  # #
747
785
  # Utility methods #
@@ -789,19 +827,29 @@ class EEPROM:
789
827
  log.debug("float_to_uint16: %f -> 0x%04x", gain, raw)
790
828
  return raw
791
829
 
830
+ def unpack_field(self, name, quiet=False):
831
+ if name not in self.fields:
832
+ log.error(f"unable to unpack unknown field {name}")
833
+ return
834
+
835
+ field = self.fields[name]
836
+ value = self.unpack(field.pos, field.data_type, label=name, quiet=quiet)
837
+ setattr(self, name, value)
838
+ return value
839
+
792
840
  ##
793
841
  # Unpack a single field at a given buffer offset of the given datatype.
794
842
  #
795
843
  # @param address a tuple of the form (buf, offset, len)
796
844
  # @param data_type see https://docs.python.org/2/library/struct.html#format-characters
797
845
  # @param label if provided, is included in debug log output
798
- def unpack(self, address, data_type, label=None):
846
+ def unpack(self, address, data_type, label=None, quiet=False):
799
847
  page = address[0]
800
848
  start_byte = address[1]
801
849
  length = address[2]
802
850
  end_byte = start_byte + length
803
851
 
804
- if page > len(self.buffers):
852
+ if page + 1 > len(self.buffers):
805
853
  log.error("error unpacking EEPROM page %d, offset %d, len %d as %s: invalid page (label %s)",
806
854
  page, start_byte, length, data_type, label, exc_info=1)
807
855
  return
@@ -827,19 +875,29 @@ class EEPROM:
827
875
  except:
828
876
  log.error("error unpacking EEPROM page %d, offset %d, len %d as %s", page, start_byte, length, data_type, exc_info=1)
829
877
 
830
- if label is None:
831
- log.debug("Unpacked [%s]: %s", data_type, unpack_result)
832
- else:
833
- log.debug("Unpacked [%s]: %s (%s)", data_type, unpack_result, label)
878
+ if not quiet:
879
+ if label is None:
880
+ log.debug("Unpacked [%s]: %s", data_type, unpack_result)
881
+ else:
882
+ log.debug("Unpacked [%s]: %s (%s)", data_type, unpack_result, label)
834
883
  return unpack_result
835
884
 
885
+ def pack_field(self, name, quiet=False):
886
+ if name not in self.fields:
887
+ log.error(f"unable to pack unknown field {name}")
888
+ return
889
+
890
+ field = self.fields[name]
891
+ value = getattr(self, name)
892
+ self.pack(field.pos, field.data_type, value, label=name, quiet=quiet)
893
+
836
894
  ##
837
895
  # Marshall or serialize a single field at a given buffer offset of the given datatype.
838
896
  #
839
897
  # @param address a tuple of the form (buf, offset, len)
840
898
  # @param data_type see https://docs.python.org/2/library/struct.html#format-characters
841
899
  # @param value value to serialize
842
- def pack(self, address, data_type, value, label=None):
900
+ def pack(self, address, data_type, value, label=None, quiet=False):
843
901
  page = address[0]
844
902
  start_byte = address[1]
845
903
  length = address[2]
@@ -873,7 +931,7 @@ class EEPROM:
873
931
  value = float(value)
874
932
  struct.pack_into(data_type, buf, start_byte, value)
875
933
 
876
- if False:
934
+ if False and not quiet:
877
935
  extra = "" if label is None else (" (%s)" % label)
878
936
  log.debug("Packed (%d, %2d, %2d) '%s' value %s -> %s%s",
879
937
  page, start_byte, length, data_type, value, buf[start_byte:end_byte], extra)
@@ -960,6 +1018,8 @@ class EEPROM:
960
1018
  log.debug(" Power Timeout: %d", self.power_timeout_sec)
961
1019
  log.debug(" Detector Timeout: %d", self.detector_timeout_sec)
962
1020
  log.debug(" Horiz Bin Mode: %d", self.multi_wavelength_calibration.get("horiz_binning_mode", default=0))
1021
+ log.debug(" Startup Scan Avg: %d", self.startup_scans_to_average)
1022
+ log.debug(" Laser Attenuator: %d", self.laser_attenuator)
963
1023
  log.debug(" Slit size: %s um", self.slit_size_um)
964
1024
  log.debug(" Start Integ Time: %d ms", self.startup_integration_time_ms)
965
1025
  log.debug(" Start Temp: %.2f degC", self.startup_temp_degC)
@@ -942,14 +942,14 @@ class FeatureIdentificationDevice(InterfaceDevice):
942
942
  def get_battery_state_raw(self):
943
943
  """Retrieves the raw battery reading and then caches it for 1 sec"""
944
944
  now = datetime.datetime.now()
945
- if self.settings.state.battery_timestamp is not None and (now - self.settings.state.battery_timestamp).total_seconds() < 1:
946
- return SpectrometerResponse(data=self.settings.state.battery_raw)
945
+ if self.settings.state.battery_timestamp is None or \
946
+ self.settings.state.battery_raw is None or \
947
+ (now - self.settings.state.battery_timestamp).total_seconds() > 1:
948
+ self.settings.state.battery_timestamp = now
949
+ response = self.get_upper_code(0x13, label="GET_BATTERY_STATE", msb_len=3)
950
+ self.settings.state.battery_raw = response.data
951
+ log.debug(f"battery_state_raw: 0x{self.settings.state.battery_raw:06x}")
947
952
 
948
- self.settings.state.battery_timestamp = now
949
- response = self.get_upper_code(0x13, label="GET_BATTERY_STATE", msb_len=3)
950
- self.settings.state.battery_raw = response.data
951
-
952
- log.debug(f"battery_state_raw: 0x{self.settings.state.battery_raw:04x}")
953
953
  return SpectrometerResponse(data=self.settings.state.battery_raw)
954
954
 
955
955
  def get_battery_percentage(self):
@@ -958,7 +958,7 @@ class FeatureIdentificationDevice(InterfaceDevice):
958
958
  lsb = (word >> 16) & 0xff
959
959
  msb = (word >> 8) & 0xff
960
960
  perc = msb + (1.0 * lsb / 256.0)
961
- log.debug("battery_perc: %.2f%%", perc)
961
+ log.debug(f"battery_perc: msb 0x{msb:02x} + lsb 0x{lsb:02x} = {perc:0.2f}%%")
962
962
  return SpectrometerResponse(data=perc)
963
963
 
964
964
  def get_battery_charging(self):
@@ -259,10 +259,11 @@ class IDSCamera:
259
259
  self.node_map = None
260
260
  self.long_name = None
261
261
  self.datastream = None
262
+ self.dumped = False
262
263
 
263
264
  self.area_scan_enabled = False
264
265
  self.take_one_request = None
265
- self.taking_acquisition = False
266
+ self.started = False
266
267
  self.shutdown_in_progress = False
267
268
  self.last_asi_timestamp = None
268
269
  self.last_area_scan_image = None
@@ -380,19 +381,50 @@ class IDSCamera:
380
381
  self.start_line = 0
381
382
  self.stop_line = self.height - 1
382
383
 
383
- # Load the default settings
384
- self.node_map.FindNode("UserSetSelector").SetCurrentEntry("Default")
385
- self.node_map.FindNode("UserSetLoad").Execute()
386
- self.node_map.FindNode("UserSetLoad").WaitUntilDone()
384
+ self.set_user_set("Default")
385
+
386
+ # default to 15ms
387
+ self.node_map.FindNode("ExposureTime").SetValue(15_000) # value in µs (note this is valid in Default, but not LongExposure)
388
+
389
+ log.debug(f"connect: successfully connected to {self.long_name}")
390
+ return True
391
+
392
+ def doing_default(self):
393
+ selector = self.node_map.FindNode("UserSetSelector")
394
+ return "Default" == selector.CurrentEntry().SymbolicValue()
395
+
396
+ def doing_long_exposure(self):
397
+ selector = self.node_map.FindNode("UserSetSelector")
398
+ return "LongExposure" == selector.CurrentEntry().SymbolicValue()
399
+
400
+ def set_user_set(self, entry):
401
+ if entry not in ["Default", "LongExposure"]:
402
+ log.error(f"ignoring unsupported UserSetSelector {entry}")
403
+ return
404
+
405
+ selector = self.node_map.FindNode("UserSetSelector")
406
+ current_entry = selector.CurrentEntry().SymbolicValue()
407
+ if entry == current_entry:
408
+ log.debug(f"set_user_set: UserSet already {entry}")
409
+ return
410
+ log.debug(f"set_user_set: changing {current_entry} -> {entry}")
411
+
412
+ was_started = self.started
413
+ if was_started:
414
+ log.debug("set_user_set: was_started, so stopping")
415
+ self.stop()
416
+
417
+ selector.SetCurrentEntry(entry)
418
+ loader = self.node_map.FindNode("UserSetLoad")
419
+ loader.Execute()
420
+ loader.WaitUntilDone()
421
+
422
+ node = self.node_map.FindNode("ExposureTime")
423
+ log.debug(f"set_user_set: applied UserSetSelector {entry}, ExposureTime range now ({node.Minimum()}, {node.Maximum()})µs")
424
+
425
+ # assuming we may need to do this after changing UserSet, but haven't verified
426
+ self.init_software_trigger()
387
427
 
388
- # initialize software trigger
389
- # @see https://www.ids-imaging.us/manuals/ids-peak/ids-peak-user-manual/2.15.0/en/acquisition-control.html
390
- log.debug("TriggerSelector set to ExposureStart")
391
- self.node_map.FindNode("TriggerSelector").SetCurrentEntry("ReadOutStart") # "ExposureStart" not supported?
392
- self.node_map.FindNode("TriggerMode").SetCurrentEntry("On")
393
- self.node_map.FindNode("TriggerSource").SetCurrentEntry("Software")
394
- self.node_map.FindNode("ExposureTime").SetValue(15_000) # value in µs
395
-
396
428
  # nodeMapRemoteDevice.FindNode("SequencerMode") .SetCurrentEntry("Off")
397
429
  # nodeMapRemoteDevice.FindNode("AcquisitionMode") .SetCurrentEntry("SingleFrame")
398
430
  # nodeMapRemoteDevice.FindNode("ExposureMode") .SetCurrentEntry("Timed")
@@ -402,10 +434,21 @@ class IDSCamera:
402
434
  # nodeMapRemoteDevice.FindNode("AcquisitionStart").Execute()
403
435
  # nodeMapRemoteDevice.FindNode("AcquisitionStart").WaitUntilDone()
404
436
 
405
- self.dump_node(name="Root")
437
+ # MZ: this can take 247ms, so only do once
438
+ if not self.dumped:
439
+ self.dump_node(name="Root")
440
+ self.dumped = True
406
441
 
407
- log.debug(f"connect: successfully connected to {self.long_name}")
408
- return True
442
+ if was_started:
443
+ log.debug("set_user_set: was_started, so re-starting")
444
+ self.start()
445
+
446
+ def init_software_trigger(self):
447
+ # @see https://www.ids-imaging.us/manuals/ids-peak/ids-peak-user-manual/2.15.0/en/acquisition-control.html
448
+ log.debug("TriggerSelector set to ExposureStart")
449
+ self.node_map.FindNode("TriggerSelector").SetCurrentEntry("ReadOutStart") # "ExposureStart" not supported?
450
+ self.node_map.FindNode("TriggerMode").SetCurrentEntry("On")
451
+ self.node_map.FindNode("TriggerSource").SetCurrentEntry("Software")
409
452
 
410
453
  def set_dark_asi(self, asi):
411
454
  self.dark = asi
@@ -430,12 +473,20 @@ class IDSCamera:
430
473
  self.stop_line = n
431
474
 
432
475
  def set_integration_time_ms(self, ms):
433
- # do we need to .stop and .start around this?
476
+ # manually clamp to apparent "multi-UserSet" range of (15ms, 120sec)
477
+ # deliberately round to ms resolution to simplify barrier comparisons
478
+ ms = int(round(min(120_000, max(ms, 15)), 0))
479
+
434
480
  us = ms * 1000.0
481
+ log.debug(f"set_integration_time_ms: ms {ms}, us {us}")
435
482
 
436
- node = self.node_map.FindNode("ExposureTime")
437
- us = max(us, node.Minimum())
438
- us = min(us, node.Maximum())
483
+ if us <= 1_001_000:
484
+ self.set_user_set("Default")
485
+ elif us >= 2_000_000:
486
+ self.set_user_set("LongExposure")
487
+ else:
488
+ # either mode can apparently support (1001, 1999ms)
489
+ pass
439
490
 
440
491
  log.debug(f"set_integration_time_ms: integration time {us} µs")
441
492
  self.node_map.FindNode("ExposureTime").SetValue(us)
@@ -457,11 +508,11 @@ class IDSCamera:
457
508
  log.debug("start: no device")
458
509
  return
459
510
 
460
- if self.taking_acquisition:
511
+ if self.started:
461
512
  log.debug("start: already running")
462
513
  return
463
514
 
464
- # self.datastream = None # kludge
515
+ self.datastream = None # kludge
465
516
  if self.datastream is None:
466
517
  log.debug("start: initializing datastream")
467
518
  self.datastream = self.device.DataStreams()[0].OpenDataStream()
@@ -512,7 +563,7 @@ class IDSCamera:
512
563
  self.datastream.StartAcquisition()
513
564
  self.node_map.FindNode("AcquisitionStart").Execute()
514
565
  self.node_map.FindNode("AcquisitionStart").WaitUntilDone()
515
- self.taking_acquisition = True
566
+ self.started = True
516
567
 
517
568
  log.debug("start: started")
518
569
  except:
@@ -549,11 +600,11 @@ class IDSCamera:
549
600
  pass
550
601
 
551
602
  def stop(self):
552
- """ Called during shutdown. """
603
+ """ Called when changing UserSet, and at shutdown. """
553
604
  if self.device is None:
554
605
  return
555
606
 
556
- if not self.taking_acquisition:
607
+ if not self.started:
557
608
  log.debug("stop: not running")
558
609
  return
559
610
 
@@ -565,7 +616,7 @@ class IDSCamera:
565
616
  # Discard all buffers from the acquisition engine
566
617
  # They remain in the announced buffer pool
567
618
  self.datastream.Flush(IDSPeak.DataStreamFlushMode_DiscardAll)
568
- self.taking_acquisition = False
619
+ self.started = False
569
620
 
570
621
  # Unlock parameters
571
622
  self.node_map.FindNode("TLParamsLocked").SetValue(0)
@@ -618,27 +669,33 @@ class IDSCamera:
618
669
  self.take_one_request = None
619
670
 
620
671
  def send_trigger(self):
672
+ # log.debug("send_trigger: start...")
621
673
  self.node_map.FindNode("TriggerSoftware").Execute()
622
674
  self.node_map.FindNode("TriggerSoftware").WaitUntilDone()
675
+ # log.debug("send_trigger: ...done")
623
676
 
624
677
  def get_spectrum(self):
678
+ # log.debug("get_spectrum: start")
625
679
  if self.datastream is None:
626
680
  log.error("get_spectrum: no datastream?!")
627
681
  return
628
682
 
629
683
  timeout_ms = int(round(1000 + 2 * max(self.integration_time_ms, self.last_integration_time_ms)))
630
684
  try:
685
+ # log.debug(f"get_spectrum: waiting on finished buffer (timeout {timeout_ms}ms)")
631
686
  buffer = self.datastream.WaitForFinishedBuffer(timeout_ms) # takes ms
632
687
  except:
633
- # log.error(f"failed on datastream.WaitForFinishedBuffer({timeout_ms}ms)", exc_info=1)
688
+ log.error(f"failed on datastream.WaitForFinishedBuffer(timeout {timeout_ms}ms)", exc_info=1)
634
689
  return
635
690
 
636
691
  # Get image from buffer (shallow copy)
692
+ # log.debug(f"get_spectrum: converting to image")
637
693
  image = EXT.BufferToImage(buffer)
638
694
 
639
695
  if True:
640
696
  # normal case, just vertically bin using the configured format
641
697
  # (pass the buffer so it can be released after conversion)
698
+ # log.debug(f"get_spectrum: vertically binning")
642
699
  spectrum = self.vertically_bin_image(image, buffer=buffer)
643
700
  else:
644
701
  # debug code to experimentally convert the same frame into every
@@ -655,6 +712,7 @@ class IDSCamera:
655
712
  log.error(f"exception during conversion to {format_name}", exc_info=1)
656
713
 
657
714
  self.last_integration_time_ms = self.integration_time_ms
715
+ # log.debug("get_spectrum: done")
658
716
  return spectrum
659
717
 
660
718
  def vertically_bin_image(self, image, format_name=None, buffer=None, save_csv=False, save_png=False):
@@ -58,6 +58,8 @@ class IDSDevice(InterfaceDevice):
58
58
  self.settings = SpectrometerSettings(self.device_id)
59
59
  self.settings.eeprom.excitation_nm_float = 785
60
60
  self.settings.eeprom.wavecal_coeffs = [0, 1, 0, 0, 0]
61
+ self.settings.eeprom.min_integration_time_ms = 15 # Default UserSet
62
+ self.settings.eeprom.max_integration_time_ms = 120_000 # LongExposure UserSet
61
63
 
62
64
  # stomp from camera
63
65
  self.settings.eeprom.model = self.camera.model_name
@@ -113,39 +115,44 @@ class IDSDevice(InterfaceDevice):
113
115
  }
114
116
 
115
117
  """
116
- json_path = os.path.join(self.config_dir, f"IDS-{self.camera.serial_number}.json")
117
- if os.path.exists(json_path):
118
- with open(json_path) as config_file:
119
- data = json.load(config_file)
120
- log.debug(f"loaded from {json_path}: {data}")
121
-
122
- def stomp(k, attr=None):
123
- if attr is None:
124
- attr = k
125
- log.debug(f"attempting to stomp {k}")
126
- else:
127
- log.debug(f"attempting to stomp {k} --> {attr}")
128
-
129
- if k in data:
130
- value = data[k]
131
- log.debug(f"stomping eeprom.{attr} = {value}")
132
- setattr(self.settings.eeprom, attr, value)
133
-
134
- for k in [ "detector_gain",
135
- "excitation_nm_float",
136
- "invert_x_axis",
137
- "horiz_binning_enabled",
138
- "horiz_binning_mode",
139
- "startup_integration_time_ms",
140
- "wavelength_coeffs",
141
- "roi_horizontal_end",
142
- "roi_horizontal_start",
143
- "roi_vertical_region_1_start",
144
- "roi_vertical_region_1_end" ]:
145
- stomp(k)
146
- for k, attr in [ [ "wp_model", "model" ],
147
- [ "wp_serial_number", "serial_number" ] ]:
148
- stomp(k, attr)
118
+ prefix = f"IDS-{self.camera.serial_number}"
119
+ suffix = ".json"
120
+ json_path = utils.find_first_file(path=self.config_dir, prefix=prefix, suffix=suffix)
121
+ if not json_path:
122
+ log.debug(f"could not find file with prefix {prefix}, suffix {suffix} in path {self.config_dir}")
123
+ return
124
+
125
+ with open(json_path) as config_file:
126
+ data = json.load(config_file)
127
+ log.debug(f"loaded from {json_path}: {data}")
128
+
129
+ def stomp(k, attr=None):
130
+ if attr is None:
131
+ attr = k
132
+ log.debug(f"attempting to stomp {k}")
133
+ else:
134
+ log.debug(f"attempting to stomp {k} --> {attr}")
135
+
136
+ if k in data:
137
+ value = data[k]
138
+ log.debug(f"stomping eeprom.{attr} = {value}")
139
+ setattr(self.settings.eeprom, attr, value)
140
+
141
+ for k in [ "detector_gain",
142
+ "excitation_nm_float",
143
+ "invert_x_axis",
144
+ "horiz_binning_enabled",
145
+ "horiz_binning_mode",
146
+ "startup_integration_time_ms",
147
+ "wavelength_coeffs",
148
+ "roi_horizontal_end",
149
+ "roi_horizontal_start",
150
+ "roi_vertical_region_1_start",
151
+ "roi_vertical_region_1_end" ]:
152
+ stomp(k)
153
+ for k, attr in [ [ "wp_model", "model" ],
154
+ [ "wp_serial_number", "serial_number" ] ]:
155
+ stomp(k, attr)
149
156
 
150
157
  def set_integration_time_ms(self, ms):
151
158
  """
@@ -213,17 +220,22 @@ class IDSDevice(InterfaceDevice):
213
220
 
214
221
  def get_spectrum(self):
215
222
  try:
223
+ # log.debug("get_spectrum: calling send_trigger")
216
224
  self.camera.send_trigger()
225
+ # log.debug("get_spectrum: back from send_trigger, calling camera.get_spectrum")
217
226
  spectrum = self.camera.get_spectrum()
227
+ # log.debug("get_spectrum: back from camera.get_spectrum")
218
228
  except:
219
229
  log.error("error getting spectrum from IDSCamera", exc_info=1)
220
230
  return SpectrometerResponse(False)
221
231
 
222
232
  if spectrum is None:
223
- # log.error("get_spectrum: received None?!")
233
+ log.debug("get_spectrum: received None")
224
234
  return SpectrometerResponse(False)
225
235
 
236
+ # log.debug("get_spectrum: applying horizontal binning")
226
237
  spectrum = self.apply_horizontal_binning(spectrum)
238
+ # log.debug("get_spectrum: done")
227
239
  return spectrum
228
240
 
229
241
  def apply_horizontal_binning(self, spectrum):
@@ -262,14 +274,15 @@ class IDSDevice(InterfaceDevice):
262
274
 
263
275
  def acquire_data(self):
264
276
  # pre-process scan averaging
265
- log.debug(f"acquire_data: scans_to_average {self.settings.state.scans_to_average}")
277
+ # log.debug(f"acquire_data: start (scans_to_average {self.settings.state.scans_to_average})")
266
278
  if self.settings.state.scans_to_average > 1:
267
279
  if self.sum_count > self.settings.state.scans_to_average:
268
280
  self.reset_averaging()
269
281
 
270
282
  reading = Reading(self.device_id)
271
-
283
+ # log.debug("acquire_data: calling get_spectrum")
272
284
  reading.spectrum = self.get_spectrum()
285
+ # log.debug("acquire_data: back from get_spectrum")
273
286
 
274
287
  if not self.camera:
275
288
  return SpectrometerResponse(False)
@@ -301,9 +314,12 @@ class IDSDevice(InterfaceDevice):
301
314
  else:
302
315
  log.debug(f"acquire_data: averaged {self.sum_count}/{self.settings.state.scans_to_average}")
303
316
 
304
- log.debug(f"acquire_data: returning {reading}")
317
+ # log.debug(f"acquire_data: returning {reading}")
305
318
  return SpectrometerResponse(data=reading)
306
319
 
320
+ def heartbeat(self, arg):
321
+ return SpectrometerResponse(data=True)
322
+
307
323
  ############################################################################
308
324
  # utility
309
325
  ############################################################################
@@ -319,6 +335,7 @@ class IDSDevice(InterfaceDevice):
319
335
  process_f["close"] = self.disconnect
320
336
 
321
337
  process_f["acquire_data"] = self.acquire_data
338
+ process_f["heartbeat"] = self.heartbeat
322
339
 
323
340
  # deliberately don't support gain_factor or detector_gain
324
341
  process_f["gain_db"] = lambda x: self.set_gain_db(float(x))
@@ -1,4 +1,4 @@
1
1
  """This package provides control of Wasatch Photonics spectrometers"""
2
2
 
3
- __version__ = "2.3.8" # used by flit and other pypi things
3
+ __version__ = "2.3.12" # used by flit and other pypi things
4
4
  version = __version__ # legacy alias
@@ -218,10 +218,25 @@ def get_default_data_dir():
218
218
  return os.path.join(os.path.expanduser("~"), "Documents", "EnlightenSpectra")
219
219
  return os.path.join(os.environ["HOME"], "EnlightenSpectra")
220
220
 
221
- ## iterate down a directory, returning pathnames that match the given pattern
221
+ def find_first_file(path, prefix=None, suffix=None, recursive=False):
222
+ pat = f"^{prefix}" if prefix else ""
223
+ if suffix:
224
+ suffix = suffix.replace(".", "\\.")
225
+ pat += f".*{suffix}"
226
+ # log.debug(f"find_first_file: pat [{pat}]")
227
+ files = get_pathnames_from_directory(rootdir=path, pattern=pat, recursive=recursive)
228
+ if len(files) < 1:
229
+ return None
230
+ else:
231
+ return files[0]
232
+
222
233
  def get_pathnames_from_directory(rootdir, pattern=None, recursive=False):
234
+ """
235
+ Iterate down a directory, returning pathnames that match the given pattern in
236
+ deterministic sorted order.
237
+ """
223
238
  pathnames = []
224
- # log.debug("searching %s matching %s with recursive %s", rootdir, pattern, recursive)
239
+ # log.debug("get_pathnames_from_directory: searching %s matching %s with recursive %s", rootdir, pattern, recursive)
225
240
  if recursive:
226
241
  for (directory, _, filenames) in os.walk(rootdir):
227
242
  for filename in filenames:
@@ -230,7 +245,7 @@ def get_pathnames_from_directory(rootdir, pattern=None, recursive=False):
230
245
  if re.search(pattern, filename):
231
246
  pathnames.append(pathname)
232
247
  else:
233
- # log.debug("%s does not match %s", pathname, pattern)
248
+ # log.debug("get_pathnames_from_directory: %s does not match %s", pathname, pattern)
234
249
  pass
235
250
  else:
236
251
  pathnames.append(pathname)
@@ -242,11 +257,12 @@ def get_pathnames_from_directory(rootdir, pattern=None, recursive=False):
242
257
  if re.search(pattern, filename):
243
258
  pathnames.append(pathname)
244
259
  else:
245
- # log.debug("%s does not match %s", pathname, pattern)
260
+ # log.debug("get_pathnames_from_directory: %s does not match %s", pathname, pattern)
246
261
  pass
247
262
  else:
248
263
  pathnames.append(pathname)
249
- # log.debug("returning %s", pathnames)
264
+ pathnames.sort()
265
+ # log.debug("get_pathnames_from_directory: returning %s", pathnames)
250
266
  return pathnames
251
267
 
252
268
  ##
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes