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,136 @@
1
+ import logging
2
+ import re
3
+
4
+ log = logging.getLogger(__name__)
5
+
6
+ class BalanceAcquisition:
7
+
8
+ INTEGRATION = 0
9
+ LASER = 1
10
+ LASER_THEN_INTEGRATION = 2
11
+
12
+ def __init__(self, mode=INTEGRATION, intensity=45000, threshold=2500, pixel=None, device=None, max_integration_time_ms=5000, max_tries=20):
13
+ self.mode = mode
14
+ self.intensity = intensity
15
+ self.threshold = threshold
16
+ self.pixel = pixel
17
+ self.device = device
18
+ self.max_tries = max_tries
19
+ self.max_integration_time_ms = min(max_integration_time_ms, device.settings.eeprom.max_integration_time_ms)
20
+
21
+ if not isinstance(self.mode, int):
22
+ self.mode = self.parse_mode(self.mode)
23
+
24
+ def using_integ(self):
25
+ return self.mode == self.INTEGRATION
26
+
27
+ def using_laser(self):
28
+ return self.mode == self.LASER
29
+
30
+ def balance(self):
31
+ if self.using_integ():
32
+ return self.balance_pass(self.adjust_integration)
33
+ elif self.using_laser():
34
+ return self.balance_pass(self.adjust_laser)
35
+ else:
36
+ # due to the way we're halving overshoots, the laser+integration
37
+ # combination likely adds little value over integration alone
38
+ if not self.balance_pass(self.adjust_laser):
39
+ return False
40
+ return self.balance_pass(self.adjust_integration)
41
+
42
+ def balance_pass(self, adjust_func):
43
+ if self.device is None:
44
+ log.error("missing device")
45
+ return
46
+
47
+ self.overshoot_count = 0
48
+ try_count = 0
49
+ same_count = 0
50
+ while True:
51
+ self.device.change_setting("acquire", True, allow_immediate = False)
52
+ reading = self.device.acquire_data()
53
+ if reading is None or isinstance(reading, bool) or reading.spectrum is None:
54
+ log.error("failed to get spectrum")
55
+ return False
56
+ spectrum = reading.spectrum
57
+
58
+ state = self.device.settings.state
59
+
60
+ peak = spectrum[self.pixel] if self.pixel is not None else max(spectrum)
61
+ delta = self.intensity - peak
62
+
63
+ log.debug("integration_time_ms %d, laser_power %d, peak %d, delta %d",
64
+ state.integration_time_ms, state.laser_power, peak, delta)
65
+
66
+ # exit case
67
+ if abs(delta) <= self.threshold:
68
+ log.debug("balanced")
69
+ return True
70
+
71
+ # adjust
72
+ last_value = state.integration_time_ms if self.using_integ() else state.laser_power
73
+ if not adjust_func(peak):
74
+ return False
75
+ new_value = state.integration_time_ms if self.using_integ() else state.laser_power
76
+
77
+ # check if we're up against a limit
78
+ if last_value == new_value:
79
+ same_count += 1
80
+ if same_count >= 3:
81
+ log.error("adjusted to same value (%s) %d times...giving up", last_value, same_count)
82
+ return False
83
+ else:
84
+ same_count = 0
85
+
86
+ try_count += 1
87
+
88
+ if try_count >= self.max_tries:
89
+ log.error("giving up after %d tries", try_count)
90
+ return False
91
+
92
+ def adjust_integration(self, peak):
93
+ state = self.device.settings.state
94
+ if peak > self.intensity:
95
+ n = int(state.integration_time_ms / 2)
96
+ self.overshoot_count += 1
97
+ if self.overshoot_count > 5:
98
+ log.error("too many overshoots")
99
+ return False
100
+ else:
101
+ n = int(1.0 * state.integration_time_ms * self.intensity / peak)
102
+
103
+ n = max(self.device.settings.eeprom.min_integration_time_ms, min(self.max_integration_time_ms, n))
104
+
105
+ log.debug("new integ = %d", n)
106
+ self.device.hardware.set_integration_time_ms(n)
107
+ return True
108
+
109
+ def adjust_laser(self, peak):
110
+ state = self.device.settings.state
111
+ if peak > self.intensity:
112
+ n = int(state.laser_power / 2)
113
+ self.overshoot_count += 1
114
+ if self.overshoot_count > 5:
115
+ log.error("too many overshoots")
116
+ return False
117
+ else:
118
+ n = int(1.0 * state.laser_power * self.intensity / peak)
119
+
120
+ n = max(1, min(100, n))
121
+
122
+ log.debug("new power = %d", n)
123
+ self.device.hardware.set_laser_power_perc(n)
124
+ return True
125
+
126
+ def parse_mode(self, s):
127
+ s = s.strip().lower()
128
+ if re.match("integ", s):
129
+ return self.INTEGRATION
130
+ elif re.match("laser.*integ", s):
131
+ return self.LASER_THEN_INTEGRATION
132
+ elif re.match("laser", s):
133
+ return self.LASER
134
+ else:
135
+ raise Exception("invalid BalanceAcquisition mode: " + s)
136
+
wasatch/CSVLoader.py ADDED
@@ -0,0 +1,188 @@
1
+ import datetime
2
+ import logging
3
+ import csv
4
+ import re
5
+
6
+ from .Reading import Reading
7
+ from .ProcessedReading import ProcessedReading
8
+
9
+ log = logging.getLogger(__name__)
10
+
11
+ ##
12
+ # A file parser to deserialize one Measurement from a column-ordered CSV file.
13
+ #
14
+ # It looks like the basic usage is to instantiate it with a CSV pathname, call
15
+ # load_data() with or without scalar_metadata, then access the .processed_reading
16
+ # and .metadata elements. So, arguably this could have been a separate
17
+ # constructor for ProcessedReading?
18
+ #
19
+ # Given the similarity between the columnar CSV and "export" file formats, it
20
+ # would be SO TEMPTING to imagine you could easily generalize them. I thought
21
+ # they're just different enough that it would be a nightmare, so here we are.
22
+ #
23
+ # It is expected that this will be able to handle "raw" columnar CSV formats as
24
+ # well, which contain no metadata, but instead begin directly with the header
25
+ # row. In that case, wavelength and wavenumber are used directly from the input
26
+ # data, as no wavecal coefficients or excitation are available.
27
+ #
28
+ # Currently this class is used by at least three callers:
29
+ #
30
+ # - enlighten.parser.ColumnFileParser
31
+ # - wasatch.MockUSBDevice
32
+ # - an OEM plugin
33
+ # - maybe a shell script or two? (check enlighten/scripts)
34
+ #
35
+ class CSVLoader:
36
+
37
+ def __init__(self, pathname, encoding="utf-8"):
38
+ self.pathname = pathname
39
+ self.encoding = encoding
40
+
41
+ # default
42
+ self.timestamp = datetime.datetime.now()
43
+
44
+ # define buckets into which we will load the data in load_data()
45
+ self.metadata = {
46
+ "pixel": []
47
+ }
48
+ self.headers = []
49
+ self.processed_reading = ProcessedReading()
50
+ self.processed_reading.reading = Reading(device_id = "LOAD:" + pathname)
51
+
52
+ def __repr__(self):
53
+ return f"CSVLoader<{self.pathname}> with ProcessedReading {self.processed_reading} and metadata {self.metadata.keys()}"
54
+
55
+ def _parse_metadata(self, line, scalar_metadata=False):
56
+ """
57
+ MZ: I'm not sure who is using this method and wants the metadata
58
+ values to be returned as lists, but it unnecessarily complicates
59
+ ColumnFileParser, so adding the scalar option.
60
+ """
61
+ line = list(line)
62
+ key = line[0]
63
+ if scalar_metadata:
64
+ value = line[1]
65
+ else:
66
+ value = line[1:] # for lists with only one element this will give []
67
+ # Useful for cases like Declared Match,,,,,,,,
68
+ self.metadata[key] = value
69
+
70
+ def _parse_header(self, line):
71
+ self.headers = [ x.lower().strip() for x in line ] # force lowercase
72
+ if "processed" in self.headers: self.processed_reading.processed = []
73
+ if "raw" in self.headers: self.processed_reading.raw = []
74
+ if "dark" in self.headers: self.processed_reading.dark = []
75
+ if "reference" in self.headers: self.processed_reading.reference = []
76
+ if "corrected" in self.headers: self.processed_reading.processed = []
77
+ # log.debug("_parse_header: headers = %s", self.headers)
78
+
79
+ def load_data(self, scalar_metadata=False):
80
+
81
+ # create empty arrays for all of these so we have someplace to put data as we load lines
82
+ self.processed_reading.processed = []
83
+ self.processed_reading.raw = []
84
+ self.processed_reading.dark = []
85
+ self.processed_reading.reference = []
86
+ self.processed_reading.wavelengths = []
87
+ self.processed_reading.wavenumbers = []
88
+
89
+ state = "reading_metadata"
90
+ data_rows_read = 0
91
+ with open(self.pathname, "r", encoding=self.encoding) as infile:
92
+ csv_lines = csv.reader(infile)
93
+ for line in csv_lines:
94
+ # skip comments and blanks
95
+ if len(line) == 0 or line[0].startswith('#'):
96
+ continue
97
+
98
+ line[-1] = line[-1].strip() # remove the \n
99
+ # log.debug("load_data[%s]: %s", state, line)
100
+
101
+ if state == "reading_metadata":
102
+
103
+ # check for end of metadata
104
+ looks_like_header = False
105
+ for tok in [ part.strip().lower() for part in line ]:
106
+ if tok in ["pixel", "wavelength", "wavenumber", "processed", "intensity", "raw"]:
107
+ looks_like_header = True
108
+ break
109
+
110
+ if looks_like_header:
111
+ self._parse_header(line)
112
+ state = "reading_data"
113
+ else:
114
+ # still in metadata
115
+ self._parse_metadata(line, scalar_metadata)
116
+
117
+ elif state == "reading_metadata_final":
118
+ self._parse_metadata(line, scalar_metadata)
119
+
120
+ elif state == "reading_data":
121
+ values = [x.strip() for x in line]
122
+
123
+ # if we find more metadata after data ended, store it but
124
+ # do not transition back
125
+ if not re.match(r'^[-+]?\d', values[0]):
126
+ state = "reading_metadata_final"
127
+ self._parse_metadata(line, scalar_metadata)
128
+ continue
129
+
130
+ # Assume each value read aligns with a known headers, but recognize that there
131
+ # could be more headers than there are values (some columns with headers may
132
+ # not actually have populated data, blank or otherwise). This is the number of
133
+ # comma-delimited fields actually read (or the number of headers, if more values
134
+ # were read than had headers).
135
+ count = min(len(self.headers), len(values))
136
+ # log.debug(f"parsing {count} fields")
137
+
138
+ for i in range(count):
139
+ header = self.headers[i]
140
+ # log.debug(f"parsing header {i} ({header})")
141
+
142
+ # SKIP nulls. Note we're APPENDING data to each list, so this means that
143
+ # if there are blanks (rather than '0' zeros) in the MIDDLE of a column,
144
+ # the resulting spectral matrix will have different-length columns and
145
+ # improperly-associated "rows".
146
+ value = values[i]
147
+ if len(value) == 0:
148
+ # log.debug(f"skipping value {i} ({value})")
149
+ continue
150
+
151
+ # MZ: honestly not sure if we should skip these or treat as zero
152
+ if value == "NA":
153
+ # log.debug(f"treating value {i} ({value}) as zero")
154
+ value = 0
155
+
156
+ # add to array
157
+ array = None
158
+ if header in ["processed", "intensity"]: array = self.processed_reading.processed
159
+ elif header == "corrected": array = self.processed_reading.processed
160
+ elif header == "raw": array = self.processed_reading.raw
161
+ elif header == "dark": array = self.processed_reading.dark
162
+ elif header == "reference": array = self.processed_reading.reference
163
+ elif header == "wavelength": array = self.processed_reading.wavelengths
164
+ elif header == "wavenumber": array = self.processed_reading.wavenumbers
165
+ elif header == "pixel": array = self.metadata["pixel"]
166
+
167
+ if array is not None:
168
+ # log.debug(f"appending to {header}: {value}")
169
+ array.append(float(value))
170
+ else:
171
+ log.error(f"load_data: null array? headers {self.headers}, header {header}, value {value}, line {line} ({self.pathname})")
172
+
173
+ if data_rows_read == 0 and header == "pixel" and int(value) != 0:
174
+ value = int(value)
175
+ # log.debug(f"first pixel = {value}")
176
+ self.processed_reading.first_pixel = value
177
+
178
+ data_rows_read += 1
179
+
180
+ # clear any arrays we ended up not filling
181
+ self.processed_reading.post_load_cleanup()
182
+
183
+ log.debug("returning processed_reading:")
184
+ self.processed_reading.dump()
185
+
186
+ log.debug(f"returning metadata {self.metadata}")
187
+
188
+ return self.processed_reading, self.metadata
@@ -0,0 +1,95 @@
1
+ import logging
2
+
3
+ log = logging.getLogger(__name__)
4
+
5
+ ## Not used - incomplete!
6
+ SETTINGS = {
7
+ # EEPROM
8
+ "adc_to_degC_coeffs": { "datatype": "float[]" },
9
+ "degC_to_dac_coeffs": { "datatype": "float[]" },
10
+ "wavelength_coeffs": { "datatype": "float[]" },
11
+ "update_eeprom": { "datatype": "object" },
12
+ "replace_eeprom": { "datatype": "object" },
13
+ "write_eeprom": { "datatype": "void" },
14
+
15
+ # Detector
16
+ "integration_time_ms": { "datatype": "int" },
17
+ "detector_tec_enable": { "datatype": "bool" },
18
+ "detector_tec_setpoint_degC": { "datatype": "int" },
19
+ "high_gain_mode_enable": { "datatype": "bool" },
20
+
21
+ # Laser
22
+ "laser_enable": { "datatype": "bool" },
23
+ "laser_power_perc": { "datatype": "float" },
24
+ "laser_power_mW": { "datatype": "float" },
25
+ "laser_temperature_setpoint_raw": { "datatype": "int" },
26
+
27
+ # Processing Modes
28
+ "area_scan_enable": { "datatype": "bool" },
29
+ "bad_pixel_mode": { "datatype": "int" },
30
+ "invert_x_axis": { "datatype": "bool" },
31
+ "scans_to_average": { "datatype": "int" },
32
+
33
+ # Secondary ADC
34
+ "enable_secondary_adc": { "datatype": "bool" },
35
+
36
+ # FPGA registers
37
+ "ccd_gain": { "datatype": "float" },
38
+ "ccd_offset": { "datatype": "int" },
39
+
40
+ # Triggering
41
+ "trigger_source": { "datatype": "int" },
42
+
43
+ # other
44
+ "log_level": { "datatype": "string" },
45
+ "max_usb_interval_ms": { "datatype": "int" },
46
+ "min_usb_interval_ms": { "datatype": "int" },
47
+ "reset_fpga": { "datatype": "void" },
48
+ }
49
+
50
+ ##
51
+ # This class encapsulates information about the "ControlObject" settings
52
+ # supported by WasatchDevice hardware classes (FID and SP). These are
53
+ # traditionally called "settings" when passing ControlObjects from ENLIGHTEN
54
+ # down into Wasatch.PY via "settings queues", but are very different from
55
+ # SpectrometerSettings (actual settings of the spectrometer, vs commands
56
+ # being passed between processes).
57
+ #
58
+ # We're not actually using this at the moment, but it would be a way to provide
59
+ # some automated data validation and type-checking on callers like wasatch-shell.py
60
+ class CommandSettings:
61
+
62
+ def get_settings(self):
63
+ return sorted(SETTINGS.keys())
64
+
65
+ def get_datatype(self, setting):
66
+ if setting not in SETTINGS:
67
+ return None
68
+
69
+ return SETTINGS[setting]["datatype"]
70
+
71
+ def valid(self, setting):
72
+ return setting in SETTINGS
73
+
74
+ def convert_type(self, setting, value):
75
+ if setting not in SETTINGS:
76
+ log.error("invalid setting: %s", setting)
77
+ return None
78
+
79
+ dt = SETTINGS[setting]["datatype"]
80
+ if dt == "bool":
81
+ return "true" in value.lower()
82
+ elif dt == "int":
83
+ return int(value)
84
+ elif dt == "float":
85
+ return float(value)
86
+ elif dt == "string":
87
+ return str(value)
88
+ elif dt == "float[]":
89
+ values = []
90
+ for tok in value.split(','):
91
+ values.append(float(tok))
92
+ return values
93
+ else:
94
+ log.debug("don't know how to convert %s %s settings", setting, dt)
95
+ return value
@@ -0,0 +1,17 @@
1
+ class ControlObject:
2
+ """
3
+ A class holding setting-value pairs sent from ENLIGHTEN to the spectrometer.
4
+ Compare to StatusMessage as an outbound (spectrometer thread -> ENLIGHTEN) counterpart.
5
+
6
+ There is no enumeration of supported settings, but CommandSettings
7
+ is an (unused) step in that direction. For now, the true master list
8
+ would be the set implemented by FeatureIdentificationDevice.write_setting.
9
+
10
+ These are also used by the alert_queue.
11
+ """
12
+ def __init__(self, setting, value):
13
+ self.setting = setting
14
+ self.value = value
15
+
16
+ def __str__(self):
17
+ return "%s -> %s" % (self.setting, self.value)
wasatch/DetectorROI.py ADDED
@@ -0,0 +1,36 @@
1
+ import logging
2
+
3
+ log = logging.getLogger(__name__)
4
+
5
+ ##
6
+ # This class represents a single Detector Region-of-Interest for a single
7
+ # "region" of DetectorRegions.
8
+ #
9
+ # We have not yet addressed the multiple wavecals required for DetectorRegions.
10
+ class DetectorROI:
11
+ def __init__(self, region, y0, y1, x0, x1, enabled=False):
12
+ self.region = region
13
+ self.y0 = y0
14
+ self.y1 = y1
15
+ self.x0 = x0
16
+ self.x1 = x1
17
+
18
+ self.enabled = enabled
19
+
20
+ def crop(self, a):
21
+ if self.x0 >= len(a) or self.x1 > len(a) + 1:
22
+ log.error("can't crop array exceeding ROI")
23
+ return a
24
+ return a[self.x0 : self.x1]
25
+
26
+ def width(self):
27
+ return self.x1 - self.x0 # not +1
28
+
29
+ def height(self):
30
+ return self.y1 - self.y0 # not +1
31
+
32
+ def __eq__(self, rhs):
33
+ return self.__dict__ == rhs.__dict__
34
+
35
+ def __str__(self):
36
+ return f"[DetectorROI: region {self.region} ({self.x0}, {self.y0}), ({self.x1}, {self.y1}), width {self.width()}, height {self.height()}, enabled {self.enabled}]"
@@ -0,0 +1,158 @@
1
+ import logging
2
+
3
+ from wasatch.DetectorROI import DetectorROI
4
+
5
+ log = logging.getLogger(__name__)
6
+
7
+ ##
8
+ # This class encapsulates all of the DetectorROI regions that have been
9
+ # configured for the current detetor. If no DetectorROI regions have been
10
+ # configured for a detector, SpectrometerState.detector_regions may be None
11
+ # (indicating the full detector is being vertically binned and read-out).
12
+ #
13
+ # Note that if DetectorROI regions have been configured for a detector, the
14
+ # total number of pixels to be read-out from the detector may be either greater
15
+ # or less than the physical number of pixels.
16
+ #
17
+ # DetectorRegions are currently only supported on the Sony IMX series of
18
+ # detector.
19
+ #
20
+ # There are two other spectrometer features that are similar to or conceptually
21
+ # overlap DetectorRegions: HorizontalROI (aka vignetting or cropping), and
22
+ # start/stop lines (vertical ROI). This feature is definitely related to
23
+ # vertical ROI, but very different from vignetting because this actually
24
+ # changes the number of pixels read-out by the spectrometer during an
25
+ # acquisition.
26
+ #
27
+ # We have not yet addressed the multiple wavecals required for DetectorRegions.
28
+ class DetectorRegions:
29
+
30
+ def __init__(self):
31
+ self.regions = {}
32
+
33
+ ##
34
+ # Will add or replace if region already exists.
35
+ #
36
+ # @param roi: DetectorROI
37
+ def add(self, roi):
38
+ self.regions[roi.region] = roi
39
+
40
+ def remove(self, roi):
41
+ region = None
42
+ if isinstance(roi, DetectorROI):
43
+ region = roi.region
44
+ else:
45
+ region = roi
46
+ self.regions.pop(region, None)
47
+
48
+ def count(self):
49
+ return len(self.regions)
50
+
51
+ def has_region(self, region): # -> bool
52
+ return region in self.regions
53
+
54
+ def get_roi(self, region):
55
+ return self.regions.get(region, None)
56
+
57
+ ## guaranteed to be in the same order as split spectra
58
+ def get_region_list(self):
59
+ return [self.regions[region] for region in sorted(self.regions)]
60
+
61
+ ## sum of widths of enabled regions
62
+ def total_pixels(self):
63
+ pixels = 0
64
+ for region in self.regions:
65
+ roi = self.regions[region]
66
+ if roi.enabled:
67
+ pixels += self.regions[region].width()
68
+ log.debug(f"total_pixels = {pixels}")
69
+ return pixels
70
+
71
+ ##
72
+ # Given a long array (like wavelengths or wavenumbers) which was presumably
73
+ # generated for a full detector, chop into the sections indicated by the
74
+ # configured regions. Some elements of the source array may appear in
75
+ # multiple output arrays, if the regions overlap in x-coordinates.
76
+ #
77
+ # Differs from split() in that some of the data will be trimmed and left on
78
+ # the floor, if it doesn't fall within any configured region. Also, some
79
+ # input values may appear in multiple output arrays.
80
+ #
81
+ # Ignores disabled regions.
82
+ #
83
+ # Example:
84
+ # - passed 1920 wavelengths (based on active_pixels_horizontal)
85
+ # - configured region 0 is from (300, 1932) (pixel space)
86
+ # - function doesn't know that 1920 was defined as 1932 - 12 (the physical
87
+ # pixel range to which the wavelengths actually correspond)
88
+ # - all function knows is that new range is 1632 pixels wide -- not where
89
+ # those 1632 pixels fall within the 1920 (in this case, they SHOULD
90
+ # start at 300 - 12 = 288
91
+ # - basically, DetectorRegions needs to know the (x, y) in pixel
92
+ # space corresponding to 'a' -- (12, 1932) in this case
93
+ # - that is what orig_roi now provides
94
+ def chop(self, a, flatten=False, orig_roi=None):
95
+ log.debug(f"chopping array of {len(a)} pixels into {self.count()} subarrays")
96
+ subarrays = []
97
+ for region in sorted(self.regions):
98
+ roi = self.regions[region]
99
+ if not roi.enabled:
100
+ continue
101
+
102
+ if roi.width() > len(a):
103
+ log.error(f"width ({roi.width}) of {roi.region} exceeds input array")
104
+ return None
105
+
106
+ start = roi.x0 if orig_roi is None else orig_roi.start - roi.x0
107
+ log.debug(f"chop: region {roi.region} of width {roi.width()} from orig {orig_roi}")
108
+ subarray = a[start : start + roi.width() + 1]
109
+ log.debug(f"chop: subarray = {subarray[:3]} .. {subarray[-3:]}")
110
+
111
+ if flatten:
112
+ subarrays.extend(subarray)
113
+ else:
114
+ subarrays.append(subarray)
115
+ return subarrays
116
+
117
+ ##
118
+ # Given a concatenated array (spectrum) which is logically composed of
119
+ # multiple shorter spectra, use the configured regions to split into the
120
+ # presumed constituent components.
121
+ #
122
+ # Differs from chop() in that no data is thrown on the floor; all is presumed
123
+ # part of one of the configured regions. Also any given source value will
124
+ # only go into a single output subspectrum.
125
+ #
126
+ # Note that split() could be called on the flattened result of chop() to dice
127
+ # a concatenated list of wavelengths for multiples regions back into
128
+ # individual per-region blocks.
129
+ #
130
+ # Ignores disabled regions.
131
+ def split(self, spectrum, flatten=False):
132
+ log.debug("splitting spectrum of %d pixels into %d subspectra", len(spectrum), self.count())
133
+ subspectra = []
134
+ start = 0
135
+ for region in sorted(self.regions):
136
+ roi = self.regions[region]
137
+ if not roi.enabled:
138
+ continue
139
+
140
+ end = start + roi.width()
141
+ if end > len(spectrum):
142
+ log.error("computed end %d of region %d overran colleted spectrum", end, region)
143
+ return None
144
+ subspectrum = spectrum[start:end]
145
+ log.debug(f"split: region {roi.region} of width {roi.width()}: {subspectrum[:3]} .. {subspectrum[-3:]}")
146
+ if flatten:
147
+ subspectra.extend(subspectrum)
148
+ else:
149
+ subspectra.append(subspectrum)
150
+ start = end
151
+ return subspectra
152
+
153
+ def __str__(self):
154
+ tok = []
155
+ for region in self.regions:
156
+ tok.append(str(self.regions[region]))
157
+ concat = ", ".join(tok)
158
+ return f"[DetectorRegions: count {self.count()}, total_pixels {self.total_pixels()}, regions: {concat}]"