dkist-processing-cryonirsp 1.3.4__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.

Potentially problematic release.


This version of dkist-processing-cryonirsp might be problematic. Click here for more details.

Files changed (111) hide show
  1. changelog/.gitempty +0 -0
  2. dkist_processing_cryonirsp/__init__.py +11 -0
  3. dkist_processing_cryonirsp/config.py +12 -0
  4. dkist_processing_cryonirsp/models/__init__.py +1 -0
  5. dkist_processing_cryonirsp/models/constants.py +248 -0
  6. dkist_processing_cryonirsp/models/exposure_conditions.py +26 -0
  7. dkist_processing_cryonirsp/models/parameters.py +296 -0
  8. dkist_processing_cryonirsp/models/tags.py +168 -0
  9. dkist_processing_cryonirsp/models/task_name.py +14 -0
  10. dkist_processing_cryonirsp/parsers/__init__.py +1 -0
  11. dkist_processing_cryonirsp/parsers/cryonirsp_l0_fits_access.py +111 -0
  12. dkist_processing_cryonirsp/parsers/cryonirsp_l1_fits_access.py +30 -0
  13. dkist_processing_cryonirsp/parsers/exposure_conditions.py +163 -0
  14. dkist_processing_cryonirsp/parsers/map_repeats.py +40 -0
  15. dkist_processing_cryonirsp/parsers/measurements.py +55 -0
  16. dkist_processing_cryonirsp/parsers/modstates.py +31 -0
  17. dkist_processing_cryonirsp/parsers/optical_density_filters.py +40 -0
  18. dkist_processing_cryonirsp/parsers/polarimetric_check.py +120 -0
  19. dkist_processing_cryonirsp/parsers/scan_step.py +412 -0
  20. dkist_processing_cryonirsp/parsers/time.py +80 -0
  21. dkist_processing_cryonirsp/parsers/wavelength.py +26 -0
  22. dkist_processing_cryonirsp/tasks/__init__.py +19 -0
  23. dkist_processing_cryonirsp/tasks/assemble_movie.py +202 -0
  24. dkist_processing_cryonirsp/tasks/bad_pixel_map.py +96 -0
  25. dkist_processing_cryonirsp/tasks/beam_boundaries_base.py +279 -0
  26. dkist_processing_cryonirsp/tasks/ci_beam_boundaries.py +55 -0
  27. dkist_processing_cryonirsp/tasks/ci_science.py +169 -0
  28. dkist_processing_cryonirsp/tasks/cryonirsp_base.py +67 -0
  29. dkist_processing_cryonirsp/tasks/dark.py +98 -0
  30. dkist_processing_cryonirsp/tasks/gain.py +251 -0
  31. dkist_processing_cryonirsp/tasks/instrument_polarization.py +447 -0
  32. dkist_processing_cryonirsp/tasks/l1_output_data.py +44 -0
  33. dkist_processing_cryonirsp/tasks/linearity_correction.py +582 -0
  34. dkist_processing_cryonirsp/tasks/make_movie_frames.py +302 -0
  35. dkist_processing_cryonirsp/tasks/mixin/__init__.py +1 -0
  36. dkist_processing_cryonirsp/tasks/mixin/beam_access.py +52 -0
  37. dkist_processing_cryonirsp/tasks/mixin/corrections.py +177 -0
  38. dkist_processing_cryonirsp/tasks/mixin/intermediate_frame.py +193 -0
  39. dkist_processing_cryonirsp/tasks/mixin/linearized_frame.py +309 -0
  40. dkist_processing_cryonirsp/tasks/mixin/shift_measurements.py +297 -0
  41. dkist_processing_cryonirsp/tasks/parse.py +281 -0
  42. dkist_processing_cryonirsp/tasks/quality_metrics.py +271 -0
  43. dkist_processing_cryonirsp/tasks/science_base.py +511 -0
  44. dkist_processing_cryonirsp/tasks/sp_beam_boundaries.py +270 -0
  45. dkist_processing_cryonirsp/tasks/sp_dispersion_axis_correction.py +484 -0
  46. dkist_processing_cryonirsp/tasks/sp_geometric.py +585 -0
  47. dkist_processing_cryonirsp/tasks/sp_science.py +299 -0
  48. dkist_processing_cryonirsp/tasks/sp_solar_gain.py +475 -0
  49. dkist_processing_cryonirsp/tasks/trial_output_data.py +61 -0
  50. dkist_processing_cryonirsp/tasks/write_l1.py +1033 -0
  51. dkist_processing_cryonirsp/tests/__init__.py +1 -0
  52. dkist_processing_cryonirsp/tests/conftest.py +456 -0
  53. dkist_processing_cryonirsp/tests/header_models.py +592 -0
  54. dkist_processing_cryonirsp/tests/local_trial_workflows/__init__.py +0 -0
  55. dkist_processing_cryonirsp/tests/local_trial_workflows/l0_cals_only.py +541 -0
  56. dkist_processing_cryonirsp/tests/local_trial_workflows/l0_to_l1.py +615 -0
  57. dkist_processing_cryonirsp/tests/local_trial_workflows/linearize_only.py +96 -0
  58. dkist_processing_cryonirsp/tests/local_trial_workflows/local_trial_helpers.py +592 -0
  59. dkist_processing_cryonirsp/tests/test_assemble_movie.py +144 -0
  60. dkist_processing_cryonirsp/tests/test_assemble_qualilty.py +517 -0
  61. dkist_processing_cryonirsp/tests/test_bad_pixel_maps.py +115 -0
  62. dkist_processing_cryonirsp/tests/test_ci_beam_boundaries.py +106 -0
  63. dkist_processing_cryonirsp/tests/test_ci_science.py +355 -0
  64. dkist_processing_cryonirsp/tests/test_corrections.py +126 -0
  65. dkist_processing_cryonirsp/tests/test_cryo_base.py +202 -0
  66. dkist_processing_cryonirsp/tests/test_cryo_constants.py +76 -0
  67. dkist_processing_cryonirsp/tests/test_dark.py +287 -0
  68. dkist_processing_cryonirsp/tests/test_gain.py +278 -0
  69. dkist_processing_cryonirsp/tests/test_instrument_polarization.py +531 -0
  70. dkist_processing_cryonirsp/tests/test_linearity_correction.py +245 -0
  71. dkist_processing_cryonirsp/tests/test_make_movie_frames.py +111 -0
  72. dkist_processing_cryonirsp/tests/test_parameters.py +266 -0
  73. dkist_processing_cryonirsp/tests/test_parse.py +1439 -0
  74. dkist_processing_cryonirsp/tests/test_quality.py +203 -0
  75. dkist_processing_cryonirsp/tests/test_sp_beam_boundaries.py +112 -0
  76. dkist_processing_cryonirsp/tests/test_sp_dispersion_axis_correction.py +155 -0
  77. dkist_processing_cryonirsp/tests/test_sp_geometric.py +319 -0
  78. dkist_processing_cryonirsp/tests/test_sp_make_movie_frames.py +121 -0
  79. dkist_processing_cryonirsp/tests/test_sp_science.py +483 -0
  80. dkist_processing_cryonirsp/tests/test_sp_solar.py +198 -0
  81. dkist_processing_cryonirsp/tests/test_trial_create_quality_report.py +79 -0
  82. dkist_processing_cryonirsp/tests/test_trial_output_data.py +251 -0
  83. dkist_processing_cryonirsp/tests/test_workflows.py +9 -0
  84. dkist_processing_cryonirsp/tests/test_write_l1.py +436 -0
  85. dkist_processing_cryonirsp/workflows/__init__.py +2 -0
  86. dkist_processing_cryonirsp/workflows/ci_l0_processing.py +77 -0
  87. dkist_processing_cryonirsp/workflows/sp_l0_processing.py +84 -0
  88. dkist_processing_cryonirsp/workflows/trial_workflows.py +190 -0
  89. dkist_processing_cryonirsp-1.3.4.dist-info/METADATA +194 -0
  90. dkist_processing_cryonirsp-1.3.4.dist-info/RECORD +111 -0
  91. dkist_processing_cryonirsp-1.3.4.dist-info/WHEEL +5 -0
  92. dkist_processing_cryonirsp-1.3.4.dist-info/top_level.txt +4 -0
  93. docs/Makefile +134 -0
  94. docs/bad_pixel_calibration.rst +47 -0
  95. docs/beam_angle_calculation.rst +53 -0
  96. docs/beam_boundary_computation.rst +88 -0
  97. docs/changelog.rst +7 -0
  98. docs/ci_science_calibration.rst +33 -0
  99. docs/conf.py +52 -0
  100. docs/index.rst +21 -0
  101. docs/l0_to_l1_cryonirsp_ci-full-trial.rst +10 -0
  102. docs/l0_to_l1_cryonirsp_ci.rst +10 -0
  103. docs/l0_to_l1_cryonirsp_sp-full-trial.rst +10 -0
  104. docs/l0_to_l1_cryonirsp_sp.rst +10 -0
  105. docs/linearization.rst +43 -0
  106. docs/make.bat +170 -0
  107. docs/requirements.txt +1 -0
  108. docs/requirements_table.rst +8 -0
  109. docs/scientific_changelog.rst +10 -0
  110. docs/sp_science_calibration.rst +59 -0
  111. licenses/LICENSE.rst +11 -0
@@ -0,0 +1,585 @@
1
+ """Cryo SP geometric task."""
2
+ import math
3
+
4
+ import numpy as np
5
+ import peakutils as pku
6
+ import scipy.ndimage as spnd
7
+ from dkist_processing_common.models.task_name import TaskName
8
+ from dkist_processing_math.arithmetic import divide_arrays_by_array
9
+ from dkist_processing_math.arithmetic import subtract_array_from_arrays
10
+ from dkist_processing_math.statistics import average_numpy_arrays
11
+ from dkist_service_configuration.logging import logger
12
+ from scipy.optimize import minimize
13
+
14
+ from dkist_processing_cryonirsp.models.tags import CryonirspTag
15
+ from dkist_processing_cryonirsp.tasks.cryonirsp_base import CryonirspTaskBase
16
+ from dkist_processing_cryonirsp.tasks.mixin.shift_measurements import ShiftMeasurementsMixin
17
+ from dkist_processing_cryonirsp.tasks.mixin.shift_measurements import SPATIAL
18
+ from dkist_processing_cryonirsp.tasks.mixin.shift_measurements import SPECTRAL
19
+
20
+ __all__ = ["SPGeometricCalibration"]
21
+
22
+
23
+ class SPGeometricCalibration(CryonirspTaskBase, ShiftMeasurementsMixin):
24
+ """Task class for computing the spectral geometry. Geometry is represented by three quantities.
25
+
26
+ - angle - The angle (in radians) between slit hairlines and pixel axes. A one dimensional array with two elements- one for each beam.
27
+
28
+ - beam offset - The [x, y] shift of beam 2 relative to beam 1 (the reference beam). Two beam offset values are computed.
29
+
30
+ - spectral shift - The shift in the spectral dimension for each beam for every spatial position needed to "straighten" the spectra so a single wavelength is at the same pixel for all slit positions.Task class for computing the spectral geometry for a SP CryoNIRSP calibration run.
31
+
32
+ Parameters
33
+ ----------
34
+ recipe_run_id : int
35
+ id of the recipe run used to identify the workflow run this task is part of
36
+ workflow_name : str
37
+ name of the workflow to which this instance of the task belongs
38
+ workflow_version : str
39
+ version of the workflow to which this instance of the task belongs
40
+
41
+ """
42
+
43
+ record_provenance = True
44
+
45
+ def run(self):
46
+ """
47
+ Run method for the task.
48
+
49
+ For each beam.
50
+
51
+ - Gather dark corrected frames
52
+ - Calculate spectral tilt (angle)
53
+ - Remove spectral tilt
54
+ - Using the angle corrected array, find the beam offset
55
+ - Write beam offset
56
+ - Calculate the spectral skew and curvature (spectral shifts)
57
+ - Write the spectral skew and curvature
58
+
59
+
60
+ Returns
61
+ -------
62
+ None
63
+
64
+ """
65
+ # The basic corrections are done outside the loop structure below as it makes these loops much
66
+ # simpler than they would be otherwise. See the comments in do_basic_corrections for more details.
67
+ with self.apm_processing_step("Basic corrections"):
68
+ self.do_basic_corrections()
69
+
70
+ for beam in range(1, self.constants.num_beams + 1):
71
+ with self.apm_task_step(f"Generating geometric calibrations for {beam = }"):
72
+ with self.apm_processing_step(f"Computing and writing angle for {beam = }"):
73
+ angle = self.compute_beam_angle(beam=beam)
74
+ self.write_angle(angle=angle, beam=beam)
75
+
76
+ with self.apm_processing_step(f"Removing angle from {beam = }"):
77
+ angle_corr_array = self.remove_beam_angle(angle=angle, beam=beam)
78
+
79
+ with self.apm_processing_step(f"Computing offset for {beam = }"):
80
+ beam_offset = self.compute_offset(
81
+ array=angle_corr_array,
82
+ beam=beam,
83
+ )
84
+ self.write_beam_offset(offset=beam_offset, beam=beam)
85
+
86
+ with self.apm_processing_step(f"Removing offset for {beam = }"):
87
+ self.remove_beam_offset(
88
+ array=angle_corr_array,
89
+ offset=beam_offset,
90
+ beam=beam,
91
+ )
92
+
93
+ with self.apm_processing_step(f"Computing spectral shifts for {beam = }"):
94
+ spec_shifts = self.compute_spectral_shifts(beam=beam)
95
+
96
+ with self.apm_writing_step(f"Writing spectral shifts for {beam = }"):
97
+ self.write_spectral_shifts(shifts=spec_shifts, beam=beam)
98
+
99
+ with self.apm_processing_step("Computing and logging quality metrics"):
100
+ no_of_raw_geo_frames: int = self.scratch.count_all(
101
+ tags=[
102
+ CryonirspTag.linearized(),
103
+ CryonirspTag.frame(),
104
+ CryonirspTag.task_solar_gain(),
105
+ ],
106
+ )
107
+
108
+ self.quality_store_task_type_counts(
109
+ task_type=TaskName.geometric.value, total_frames=no_of_raw_geo_frames
110
+ )
111
+
112
+ def basic_gain_corrected_data(self, beam: int) -> np.ndarray:
113
+ """
114
+ Get dark and lamp gain corrected data array for a single beam.
115
+
116
+ Parameters
117
+ ----------
118
+ beam : int
119
+ The current beam being processed
120
+
121
+ Returns
122
+ -------
123
+ np.ndarray
124
+ Dark corrected data array
125
+ """
126
+ array_generator = self.intermediate_frame_load_intermediate_arrays(
127
+ tags=[CryonirspTag.task("GC_BASIC_GAIN_CORRECTED"), CryonirspTag.beam(beam)]
128
+ )
129
+ return average_numpy_arrays(array_generator)
130
+
131
+ def basic_dark_bp_corrected_data(self, beam: int) -> np.ndarray:
132
+ """
133
+ Get dark and bad pixel corrected data array for a single beam.
134
+
135
+ Parameters
136
+ ----------
137
+ beam : int
138
+ The current beam being processed
139
+
140
+ Returns
141
+ -------
142
+ np.ndarray
143
+ Dark and bad pixel corrected data array
144
+ """
145
+ array_generator = self.intermediate_frame_load_intermediate_arrays(
146
+ tags=[CryonirspTag.task("GC_BASIC_DARK_BP_CORRECTED"), CryonirspTag.beam(beam)]
147
+ )
148
+ return average_numpy_arrays(array_generator)
149
+
150
+ def offset_corrected_data(self, beam: int) -> np.ndarray:
151
+ """
152
+ Array for a single beam that has been corrected for the x/y beam offset.
153
+
154
+ Parameters
155
+ ----------
156
+ beam
157
+ The current beam being processed
158
+
159
+ Returns
160
+ -------
161
+ np.ndarray
162
+ Offset corrected data array
163
+ """
164
+ array_generator = self.intermediate_frame_load_intermediate_arrays(
165
+ tags=[CryonirspTag.task("GC_OFFSET"), CryonirspTag.beam(beam)]
166
+ )
167
+ return average_numpy_arrays(array_generator)
168
+
169
+ def do_basic_corrections(self):
170
+ """Apply dark, bad pixel and lamp gain corrections to all data that will be used for Geometric Calibration."""
171
+ # There is likely only a single exposure conditions tuple in the list, but we iterate over the list
172
+ # in case there are multiple exposure conditions tuples. We also need a specific exposure conditions tag
173
+ # to ensure we get the proper dark arrays to use in the correction.
174
+ for exposure_conditions in self.constants.solar_gain_exposure_conditions_list:
175
+ for beam in range(1, self.constants.num_beams + 1):
176
+ logger.info(f"Starting basic reductions for {exposure_conditions = } and {beam = }")
177
+ try:
178
+ dark_array = self.intermediate_frame_load_dark_array(
179
+ beam=beam, exposure_conditions=exposure_conditions
180
+ )
181
+ except StopIteration as e:
182
+ raise ValueError(f"No matching dark found for {exposure_conditions = }") from e
183
+
184
+ lamp_gain_array = self.intermediate_frame_load_lamp_gain_array(
185
+ beam=beam,
186
+ )
187
+
188
+ input_solar_arrays = self.linearized_frame_gain_array_generator(
189
+ gain_type=TaskName.solar_gain.value,
190
+ beam=beam,
191
+ exposure_conditions=exposure_conditions,
192
+ )
193
+
194
+ avg_solar_array = average_numpy_arrays(input_solar_arrays)
195
+
196
+ dark_corrected_solar_array = next(
197
+ subtract_array_from_arrays(arrays=avg_solar_array, array_to_subtract=dark_array)
198
+ )
199
+
200
+ bad_pixel_map = self.intermediate_frame_load_bad_pixel_map(beam=beam)
201
+ bad_pixel_corrected_array = self.corrections_correct_bad_pixels(
202
+ dark_corrected_solar_array, bad_pixel_map
203
+ )
204
+ logger.info(f"Writing bad pixel corrected data for {beam=}")
205
+ self.intermediate_frame_write_arrays(
206
+ arrays=bad_pixel_corrected_array,
207
+ beam=beam,
208
+ task="GC_BASIC_DARK_BP_CORRECTED",
209
+ )
210
+ gain_corrected_solar_array = next(
211
+ divide_arrays_by_array(bad_pixel_corrected_array, lamp_gain_array)
212
+ )
213
+ logger.info(f"Writing gain corrected data for {beam=}")
214
+ self.intermediate_frame_write_arrays(
215
+ arrays=gain_corrected_solar_array,
216
+ beam=beam,
217
+ task="GC_BASIC_GAIN_CORRECTED",
218
+ )
219
+
220
+ def compute_beam_angle(self, beam: int) -> float:
221
+ """
222
+ Compute the angle between dispersion and pixel axes for a given beam.
223
+
224
+ The algorithm works as follows:
225
+
226
+ 1. Load the corrected solar array for this beam
227
+ 2. Compute a gradient array by shifting the array along the spatial axis (along the slit) and
228
+ calculating a normalized finite difference with the original array.
229
+ 3. Compute 2D slices for two strips that are on either side of the spectral center.
230
+ 4. Extract the spatial strips as arrays and compute the median values along their spectral axis.
231
+ 5. Compute the relative shift of the right strip to the left strip (this is the shift along the spatial axis)
232
+ 6. Compute the angular rotation of the beam relative to the array axes from the shift
233
+ and the separation of the strips along the spectral axis
234
+
235
+ Returns
236
+ -------
237
+ The beam rotation angle in radians
238
+ """
239
+ # Step 1
240
+ # Do not use a gain corrected image here, as it will cancel out the slit structure
241
+ # that is used for the shift measurement computations
242
+ gain_array = self.intermediate_frame_load_lamp_gain_array(beam=beam)
243
+
244
+ full_spatial_size, full_spectral_size = gain_array.shape
245
+
246
+ # Get the params for the strips
247
+ spectral_offset = math.ceil(
248
+ full_spectral_size * self.parameters.geo_strip_spectral_offset_size_fraction
249
+ )
250
+
251
+ # Steps 2-5:
252
+ shift = self.shift_measurements_compute_shift_along_axis(
253
+ axis=SPATIAL,
254
+ array_1=gain_array,
255
+ array_2=gain_array,
256
+ array_1_offset=(0, -spectral_offset),
257
+ array_2_offset=(0, spectral_offset),
258
+ upsample_factor=self.parameters.geo_upsample_factor,
259
+ )
260
+
261
+ logger.info(f"Measured shift of beam {beam} = {shift}")
262
+
263
+ # Step 6
264
+ beam_angle = np.arctan(shift / (2 * spectral_offset))
265
+
266
+ logger.info(f"Measured angle for beam {beam} = {np.rad2deg(beam_angle):0.3f} deg")
267
+
268
+ return beam_angle
269
+
270
+ def remove_beam_angle(self, angle: float, beam: int) -> np.ndarray:
271
+ """
272
+ De-rotate the beam array using the measured angle to align the slit with the array axes.
273
+
274
+ Parameters
275
+ ----------
276
+ angle : float
277
+ The measured beam rotation angle (in radians)
278
+ beam : int
279
+ The current beam being processed
280
+
281
+ Returns
282
+ -------
283
+ np.ndarray
284
+ The corrected array
285
+ """
286
+ rotated_array = self.basic_gain_corrected_data(beam=beam)
287
+ corrected_array = next(self.corrections_correct_geometry(rotated_array, angle=angle))
288
+ return corrected_array
289
+
290
+ def compute_offset(self, array: np.ndarray, beam: int) -> np.ndarray:
291
+ """
292
+ Higher-level helper function to compute the (x, y) offset between beams.
293
+
294
+ Sets beam 1 as the reference beam or computes the offset of beam 2 relative to beam 1.
295
+
296
+ Parameters
297
+ ----------
298
+ array : np.ndarray
299
+ Beam data
300
+ beam : int
301
+ The current beam being processed
302
+
303
+ Returns
304
+ -------
305
+ np.ndarray
306
+ (x, y) offset between beams
307
+ """
308
+ if beam == 1:
309
+ self.reference_array = array
310
+ return np.zeros(2)
311
+
312
+ spatial_shift = self.shift_measurements_compute_shift_along_axis(
313
+ SPATIAL,
314
+ self.reference_array,
315
+ array,
316
+ upsample_factor=self.parameters.geo_upsample_factor,
317
+ )
318
+ spectral_shift = self.shift_measurements_compute_shift_along_axis(
319
+ SPECTRAL,
320
+ self.reference_array,
321
+ array,
322
+ upsample_factor=self.parameters.geo_upsample_factor,
323
+ )
324
+ shift = np.array([spatial_shift, spectral_shift])
325
+ logger.info(f"Offset for {beam = } is {np.array2string(shift, precision=3)}")
326
+ return shift
327
+
328
+ def remove_beam_offset(self, array: np.ndarray, offset: np.ndarray, beam: int) -> None:
329
+ """
330
+ Shift an array by some offset (to make it in line with the reference array).
331
+
332
+ Parameters
333
+ ----------
334
+ array : np.ndarray
335
+ Beam data
336
+ offset : np.ndarray
337
+ The beam offset for the current beam
338
+ beam : int
339
+ The current beam being processed
340
+
341
+ Returns
342
+ -------
343
+ None
344
+
345
+ """
346
+ corrected_array = next(self.corrections_correct_geometry(array, shift=offset))
347
+ self.intermediate_frame_write_arrays(arrays=corrected_array, beam=beam, task="GC_OFFSET")
348
+
349
+ def compute_spectral_shifts(self, beam: int) -> np.ndarray:
350
+ """
351
+ Compute the spectral 'curvature'.
352
+
353
+ I.e., the spectral shift at each slit position needed to have wavelength be constant across a single spatial
354
+ pixel. Generally, the algorithm is:
355
+
356
+ 1. Identify the reference array spectrum as the center of the slit
357
+ 2. For each slit position, make an initial guess of the shift via correlation
358
+ 3. Take the initial guesses and use them in a chisq minimizer to refine the shifts
359
+ 4. Interpolate over those shifts identified as too large
360
+ 5. Remove the mean shift so the total shift amount is minimized
361
+
362
+ Parameters
363
+ ----------
364
+ beam
365
+ The current beam being processed
366
+
367
+ Returns
368
+ -------
369
+ np.ndarray
370
+ Spectral shift for a single beam
371
+ """
372
+ logger.info(f"Computing spectral shifts for beam {beam}")
373
+ beam_array = self.offset_corrected_data(beam=beam)
374
+ spatial_size = beam_array.shape[0]
375
+
376
+ if beam == 1:
377
+ # Use the same reference spectrum for both beams.
378
+ # We pick the spectrum from the center of the slit, with a buffer of 10 pixels on either side
379
+ middle_row = spatial_size // 2
380
+ self.ref_spec = np.nanmedian(beam_array[middle_row - 10 : middle_row + 10, :], axis=0)
381
+
382
+ beam_shifts = np.empty(spatial_size) * np.nan
383
+ for i in range(spatial_size):
384
+ target_spec = beam_array[i, :]
385
+
386
+ initial_guess = self.compute_initial_spec_shift_guess(
387
+ ref_spec=self.ref_spec, target_spec=target_spec, beam=beam, pos=i
388
+ )
389
+
390
+ shift = self.compute_single_spec_shift(
391
+ ref_spec=self.ref_spec,
392
+ target_spec=target_spec,
393
+ initial_guess=initial_guess,
394
+ beam=beam,
395
+ pos=i,
396
+ )
397
+
398
+ beam_shifts[i] = shift
399
+
400
+ # Subtract the average so we shift my a minimal amount
401
+ if beam == 1:
402
+ # Use the same mean shift for both beams to avoid any relative shifts between the two.
403
+ self.mean_shifts = np.nanmean(beam_shifts)
404
+ logger.info(f"Mean of spectral shifts = {self.mean_shifts}")
405
+
406
+ beam_shifts -= self.mean_shifts
407
+ self.intermediate_frame_write_arrays(
408
+ arrays=beam_shifts,
409
+ beam=beam,
410
+ task="GC_RAW_SPECTRAL_SHIFTS",
411
+ )
412
+
413
+ # Finally, fit the shifts and return the resulting polynomial. Any "bad" fits were set to NaN and will be
414
+ # interpolated over.
415
+ poly_fit_order = self.parameters.geo_poly_fit_order
416
+ nan_idx = np.isnan(beam_shifts)
417
+ poly = np.poly1d(
418
+ np.polyfit(np.arange(spatial_size)[~nan_idx], beam_shifts[~nan_idx], poly_fit_order)
419
+ )
420
+
421
+ return poly(np.arange(spatial_size))
422
+
423
+ def compute_initial_spec_shift_guess(
424
+ self, *, ref_spec: np.ndarray, target_spec: np.ndarray, beam: int, pos: int
425
+ ) -> float:
426
+ """
427
+ Make a rough guess for the offset between two spectra.
428
+
429
+ A basic correlation is performed and the location of the peak sets the initial guess. If more than one strong
430
+ peak is found then the peak locations are averaged together.
431
+ """
432
+ corr = np.correlate(
433
+ target_spec - np.nanmean(target_spec),
434
+ ref_spec - np.nanmean(ref_spec),
435
+ mode="same",
436
+ )
437
+ # Truncate the correlation to contain only allowable shifts
438
+ max_shift = self.parameters.geo_max_shift
439
+ mid_position = corr.size // 2
440
+ start = mid_position - max_shift
441
+ stop = mid_position + max_shift + 1
442
+ truncated_corr = corr[start:stop]
443
+
444
+ # This min_dist ensures we only find a single peak in each correlation signal
445
+ pidx = pku.indexes(truncated_corr, min_dist=truncated_corr.size)
446
+ initial_guess = 1 * (pidx - truncated_corr.size // 2)
447
+
448
+ # These edge-cases are very rare, but do happen sometimes
449
+ if initial_guess.size == 0:
450
+ logger.info(
451
+ f"Spatial position {pos} in {beam=} doesn't have a correlation peak. Initial guess set to 0"
452
+ )
453
+ initial_guess = 0.0
454
+
455
+ elif initial_guess.size > 1:
456
+ logger.info(
457
+ f"Spatial position {pos} in {beam=} has more than one correlation peak ({initial_guess}). Initial guess set to mean ({np.nanmean(initial_guess)})"
458
+ )
459
+ initial_guess = np.nanmean(initial_guess)
460
+
461
+ return initial_guess
462
+
463
+ def compute_single_spec_shift(
464
+ self,
465
+ *,
466
+ ref_spec: np.ndarray,
467
+ target_spec: np.ndarray,
468
+ initial_guess: float,
469
+ beam: int,
470
+ pos: int,
471
+ ) -> float:
472
+ """
473
+ Refine the 1D offset between two spectra.
474
+
475
+ A 1-parameter minimization is performed where the goodness-of-fit parameter is simply the Chisq difference
476
+ between the reference spectrum and shifted target spectrum.
477
+ """
478
+ shift = minimize(
479
+ self.shift_chisq,
480
+ np.atleast_1d(initial_guess),
481
+ args=(ref_spec, target_spec),
482
+ method="nelder-mead",
483
+ ).x[0]
484
+
485
+ max_shift = self.parameters.geo_max_shift
486
+ if np.abs(shift) > max_shift:
487
+ # Didn't find a good peak
488
+ logger.info(
489
+ f"shift in {beam = } at spatial pixel {pos} out of range ({shift} > {max_shift})"
490
+ )
491
+ shift = np.nan
492
+
493
+ return shift
494
+
495
+ @staticmethod
496
+ def shift_chisq(par: np.ndarray, ref_spec: np.ndarray, spec: np.ndarray) -> float:
497
+ """
498
+ Goodness of fit calculation for a simple shift. Uses simple chisq as goodness of fit.
499
+
500
+ Less robust than SPGainCalibration's `refine_shift`, but much faster.
501
+
502
+ Parameters
503
+ ----------
504
+ par : np.ndarray
505
+ Spectral shift being optimized
506
+
507
+ ref_spec : np.ndarray
508
+ Reference spectra
509
+
510
+ spec : np.ndarray
511
+ Spectra being fitted
512
+
513
+ Returns
514
+ -------
515
+ float
516
+ Sum of chisquared fit
517
+
518
+ """
519
+ shift = par[0]
520
+ shifted_spec = spnd.shift(spec, -shift, mode="constant", cval=np.nan)
521
+ chisq = np.nansum((ref_spec - shifted_spec) ** 2 / ref_spec)
522
+ return chisq
523
+
524
+ def write_angle(self, angle: float, beam: int) -> None:
525
+ """
526
+ Write the angle component of the geometric calibration for a single beam.
527
+
528
+ Parameters
529
+ ----------
530
+ angle : float
531
+ The beam angle (radians) for the current beam
532
+
533
+ beam : int
534
+ The current beam being processed
535
+
536
+ Returns
537
+ -------
538
+ None
539
+ """
540
+ array = np.array([angle])
541
+ self.intermediate_frame_write_arrays(
542
+ arrays=array, beam=beam, task_tag=CryonirspTag.task_geometric_angle()
543
+ )
544
+
545
+ def write_beam_offset(self, offset: np.ndarray, beam: int) -> None:
546
+ """
547
+ Write the beam offset component of the geometric calibration for a single beam.
548
+
549
+ Parameters
550
+ ----------
551
+ offset : np.ndarray
552
+ The beam offset for the current beam
553
+
554
+ beam : int
555
+ The current beam being processed
556
+
557
+ Returns
558
+ -------
559
+ None
560
+
561
+ """
562
+ self.intermediate_frame_write_arrays(
563
+ arrays=offset, beam=beam, task_tag=CryonirspTag.task_geometric_offset()
564
+ )
565
+
566
+ def write_spectral_shifts(self, shifts: np.ndarray, beam: int) -> None:
567
+ """
568
+ Write the spectral shift component of the geometric calibration for a single beam.
569
+
570
+ Parameters
571
+ ----------
572
+ shifts : np.ndarray
573
+ The spectral shifts for the current beam
574
+
575
+ beam : int
576
+ The current beam being processed
577
+
578
+ Returns
579
+ -------
580
+ None
581
+
582
+ """
583
+ self.intermediate_frame_write_arrays(
584
+ arrays=shifts, beam=beam, task_tag=CryonirspTag.task_geometric_sepectral_shifts()
585
+ )