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,302 @@
1
+ """Cryonirsp make movie frames task."""
2
+ from abc import ABC
3
+ from abc import abstractmethod
4
+
5
+ import numpy as np
6
+ from astropy.io import fits
7
+ from astropy.visualization import ZScaleInterval
8
+ from dkist_processing_common.codecs.fits import fits_access_decoder
9
+ from dkist_processing_common.codecs.fits import fits_hdulist_encoder
10
+ from dkist_service_configuration.logging import logger
11
+
12
+ from dkist_processing_cryonirsp.models.tags import CryonirspTag
13
+ from dkist_processing_cryonirsp.parsers.cryonirsp_l1_fits_access import CryonirspL1FitsAccess
14
+ from dkist_processing_cryonirsp.tasks.cryonirsp_base import CryonirspTaskBase
15
+
16
+ __all__ = ["MakeCryonirspMovieFrames", "SPMakeCryonirspMovieFrames"]
17
+
18
+
19
+ class MakeCryonirspMovieFramesBase(CryonirspTaskBase, ABC):
20
+ """Create CryoNIRSP movie frames common functionality for the Context Imager (CI) and Spectropolarimeter (SP)."""
21
+
22
+ def run(self):
23
+ """Create movie frames using all stokes states if they exist, otherwise only use intensity."""
24
+ if self.constants.correct_for_polarization:
25
+ with self.apm_task_step("Make full stokes movie"):
26
+ self.make_full_stokes_movie_frames()
27
+ else:
28
+ with self.apm_task_step("Make intensity only movie"):
29
+ self.make_intensity_movie_frames()
30
+
31
+ @abstractmethod
32
+ def make_full_stokes_movie_frames(self):
33
+ """Make a movie that combines each of the stokes frames into a single movie frame."""
34
+
35
+ @abstractmethod
36
+ def make_intensity_movie_frames(self):
37
+ """Make a movie out of stokes I frames."""
38
+
39
+ @staticmethod
40
+ def scale_for_rendering(data: np.ndarray):
41
+ """Scale the calibrated frame data using a normalization function to facilitate display as a movie frame."""
42
+ zscale = ZScaleInterval()
43
+ return zscale(data)
44
+
45
+ @staticmethod
46
+ def grid_movie_frame(
47
+ top_left: np.ndarray,
48
+ top_right: np.ndarray,
49
+ bottom_left: np.ndarray,
50
+ bottom_right: np.ndarray,
51
+ ) -> np.ndarray:
52
+ """Combine multiple arrays into a 2x2 grid."""
53
+ result = np.concatenate(
54
+ (
55
+ np.concatenate((top_left, top_right), axis=1),
56
+ np.concatenate((bottom_left, bottom_right), axis=1),
57
+ ),
58
+ axis=0,
59
+ )
60
+ return result
61
+
62
+ def get_movie_header(
63
+ self, map_scan: int, scan_step: int, meas_num: int, stokes_state: str
64
+ ) -> fits.Header:
65
+ """Create a header to use on a movie frame based on a calibrated frame."""
66
+ calibrated_frame = next(
67
+ self.read(
68
+ tags=[
69
+ CryonirspTag.frame(),
70
+ CryonirspTag.calibrated(),
71
+ CryonirspTag.stokes(stokes_state),
72
+ CryonirspTag.meas_num(meas_num),
73
+ CryonirspTag.map_scan(map_scan),
74
+ CryonirspTag.scan_step(scan_step),
75
+ ],
76
+ decoder=fits_access_decoder,
77
+ fits_access_class=CryonirspL1FitsAccess,
78
+ )
79
+ )
80
+ header = calibrated_frame.header
81
+ return header
82
+
83
+
84
+ # See note below on `SPMakeCryonirspMovieFrames`
85
+ class MakeCryonirspMovieFrames(MakeCryonirspMovieFramesBase):
86
+ """Make CryoNIRSP movie frames for the Context Imager and tag with CryonirspTag.movie_frame()."""
87
+
88
+ def make_full_stokes_movie_frames(self):
89
+ """Make a movie that combines each of the stokes frames into a single movie frame."""
90
+ self.make_intensity_movie_frames()
91
+
92
+ def make_intensity_movie_frames(self):
93
+ """Make a movie out of stokes I frames."""
94
+ for map_scan in range(1, self.constants.num_map_scans + 1):
95
+ for scan_step in range(1, self.constants.num_scan_steps + 1):
96
+ logger.info(f"Generate Stokes-I movie frame for {map_scan=} and {scan_step=}")
97
+ movie_frame_hdu = self.make_intensity_frame(map_scan=map_scan, scan_step=scan_step)
98
+ logger.info(f"Writing Stokes-I movie frame for {map_scan=} and {scan_step=}")
99
+ self.write(
100
+ data=fits.HDUList([movie_frame_hdu]),
101
+ tags=[
102
+ CryonirspTag.map_scan(map_scan),
103
+ CryonirspTag.scan_step(scan_step),
104
+ CryonirspTag.movie_frame(),
105
+ ],
106
+ encoder=fits_hdulist_encoder,
107
+ )
108
+
109
+ def make_full_stokes_frame(self, map_scan: int, scan_step: int) -> fits.PrimaryHDU:
110
+ """Create a movie frame (data + header) with the stokes frames (IQUV) combined into top left, top right, bottom left, bottom right quadrants respectively."""
111
+ meas_num = 1 # Use only the first measurement if there are multiple measurements.
112
+ stokes_i = self.get_movie_frame(
113
+ map_scan=map_scan, scan_step=scan_step, meas_num=meas_num, stokes_state="I"
114
+ )
115
+ stokes_q = self.get_movie_frame(
116
+ map_scan=map_scan, scan_step=scan_step, meas_num=meas_num, stokes_state="Q"
117
+ )
118
+ stokes_u = self.get_movie_frame(
119
+ map_scan=map_scan, scan_step=scan_step, meas_num=meas_num, stokes_state="U"
120
+ )
121
+ stokes_v = self.get_movie_frame(
122
+ map_scan=map_scan, scan_step=scan_step, meas_num=meas_num, stokes_state="V"
123
+ )
124
+ movie_frame = self.grid_movie_frame(
125
+ top_left=stokes_i,
126
+ top_right=stokes_q,
127
+ bottom_left=stokes_u,
128
+ bottom_right=stokes_v,
129
+ )
130
+ movie_header = self.get_movie_header(
131
+ map_scan=map_scan, scan_step=scan_step, meas_num=meas_num, stokes_state="I"
132
+ )
133
+ return fits.PrimaryHDU(header=movie_header, data=movie_frame)
134
+
135
+ def make_intensity_frame(self, map_scan: int, scan_step: int) -> fits.PrimaryHDU:
136
+ """Create a movie frame (data + header) with just the stokes I frames."""
137
+ meas_num = 1 # Use only the first measurement if there are multiple measurements.
138
+ stokes_i = self.get_movie_frame(
139
+ map_scan=map_scan, scan_step=scan_step, meas_num=meas_num, stokes_state="I"
140
+ )
141
+ movie_header = self.get_movie_header(
142
+ map_scan=map_scan, scan_step=scan_step, meas_num=meas_num, stokes_state="I"
143
+ )
144
+ return fits.PrimaryHDU(header=movie_header, data=stokes_i)
145
+
146
+ def get_movie_frame(
147
+ self, map_scan: int, scan_step: int, meas_num: int, stokes_state: str
148
+ ) -> np.ndarray:
149
+ """Retrieve the calibrated frame data for the first frame which matches the input parameters and transform it into a movie frame (i.e. normalize the values)."""
150
+ calibrated_frame = next(
151
+ self.read(
152
+ tags=[
153
+ CryonirspTag.frame(),
154
+ CryonirspTag.calibrated(),
155
+ CryonirspTag.stokes(stokes_state),
156
+ CryonirspTag.meas_num(meas_num),
157
+ CryonirspTag.map_scan(map_scan),
158
+ CryonirspTag.scan_step(scan_step),
159
+ ],
160
+ decoder=fits_access_decoder,
161
+ fits_access_class=CryonirspL1FitsAccess,
162
+ )
163
+ )
164
+ movie_frame = self.scale_for_rendering(calibrated_frame.data)
165
+ return movie_frame
166
+
167
+
168
+ # NOTE:
169
+ # This task isn't used right now because the SP movies need some better handling of the wavelength dimension.
170
+ # For the time being both arms use the `MakeCryonirspMovieFrames` task above.
171
+ # See PR #91 for more information.
172
+ class SPMakeCryonirspMovieFrames(MakeCryonirspMovieFramesBase):
173
+ """Make CryoNIRSP movie frames for the Spectropolarimeter and tag with CryonirspTag.movie_frame()."""
174
+
175
+ def make_full_stokes_movie_frames(self):
176
+ """Make a movie that combines each of the stokes frames into a single movie frame."""
177
+ for map_scan in range(1, self.constants.num_map_scans + 1):
178
+ logger.info(f"Generate full stokes movie frame for {map_scan=}")
179
+ movie_frame_hdu = self.make_full_stokes_frame(map_scan=map_scan)
180
+
181
+ logger.info(f"Writing full stokes movie frame for {map_scan=}")
182
+ self.write(
183
+ data=fits.HDUList([movie_frame_hdu]),
184
+ tags=[
185
+ CryonirspTag.map_scan(map_scan),
186
+ CryonirspTag.movie_frame(),
187
+ ],
188
+ encoder=fits_hdulist_encoder,
189
+ )
190
+
191
+ def make_intensity_movie_frames(self):
192
+ """Make a movie out of stokes I frames."""
193
+ for map_scan in range(1, self.constants.num_map_scans + 1):
194
+ logger.info(f"Generate intensity movie frame for {map_scan=}")
195
+ movie_frame_hdu = self.make_intensity_frame(map_scan=map_scan)
196
+
197
+ logger.info(f"Writing intensity movie frame for {map_scan=}")
198
+ self.write(
199
+ data=fits.HDUList([movie_frame_hdu]),
200
+ tags=[
201
+ CryonirspTag.map_scan(map_scan),
202
+ CryonirspTag.movie_frame(),
203
+ ],
204
+ encoder=fits_hdulist_encoder,
205
+ )
206
+
207
+ def make_full_stokes_frame(self, map_scan: int) -> fits.PrimaryHDU:
208
+ """Create a movie frame (data + header) with the stokes frames (IQUV) combined into top left, top right, bottom left, bottom right quadrants respectively."""
209
+ meas_num = 1 # Use only the first measurement if there are multiple measurements.
210
+ stokes_i = self.get_movie_frame(map_scan=map_scan, meas_num=meas_num, stokes_state="I")
211
+ stokes_q = self.get_movie_frame(map_scan=map_scan, meas_num=meas_num, stokes_state="Q")
212
+ stokes_u = self.get_movie_frame(map_scan=map_scan, meas_num=meas_num, stokes_state="U")
213
+ stokes_v = self.get_movie_frame(map_scan=map_scan, meas_num=meas_num, stokes_state="V")
214
+ movie_frame = self.grid_movie_frame(
215
+ top_left=stokes_i,
216
+ top_right=stokes_q,
217
+ bottom_left=stokes_u,
218
+ bottom_right=stokes_v,
219
+ )
220
+ # Here we make the full map have the same header as the first scan step.
221
+ movie_header = self.get_movie_header(
222
+ map_scan=map_scan, scan_step=1, meas_num=meas_num, stokes_state="I"
223
+ )
224
+ return fits.PrimaryHDU(header=movie_header, data=movie_frame)
225
+
226
+ def make_intensity_frame(self, map_scan: int) -> fits.PrimaryHDU:
227
+ """Create a movie frame (data + header) with just the stokes I frames."""
228
+ meas_num = 1 # Use only the first measurement if there are multiple measurements.
229
+ stokes_i = self.get_movie_frame(map_scan=map_scan, meas_num=meas_num, stokes_state="I")
230
+ movie_header = self.get_movie_header(
231
+ map_scan=map_scan, scan_step=1, meas_num=meas_num, stokes_state="I"
232
+ )
233
+ return fits.PrimaryHDU(header=movie_header, data=stokes_i)
234
+
235
+ def get_movie_frame(self, map_scan: int, meas_num: int, stokes_state: str) -> np.ndarray:
236
+ """Retrieve the calibrated frame data for the first frame which matches the input parameters and transform it into a movie frame (i.e. normalize the values)."""
237
+ if self.constants.num_scan_steps == 1:
238
+ calibrated_frame = self.get_spectral_calibrated_frame(
239
+ map_scan=map_scan, meas_num=meas_num, stokes_state=stokes_state
240
+ )
241
+ else:
242
+ calibrated_frame = self.get_integrated_calibrated_frame(
243
+ map_scan=map_scan, meas_num=meas_num, stokes_state=stokes_state
244
+ )
245
+ movie_frame = self.scale_for_rendering(calibrated_frame.data)
246
+ return movie_frame
247
+
248
+ def get_spectral_calibrated_frame(
249
+ self, map_scan: int, meas_num: int, stokes_state: str
250
+ ) -> np.ndarray:
251
+ """Retrieve a calibrated frame for a single scan step (no integration)."""
252
+ scan_step = 1 # There is only a single scan step in a spectral movie
253
+ calibrated_frame = next(
254
+ self.read(
255
+ tags=[
256
+ CryonirspTag.frame(),
257
+ CryonirspTag.calibrated(),
258
+ CryonirspTag.stokes(stokes_state),
259
+ CryonirspTag.meas_num(meas_num),
260
+ CryonirspTag.map_scan(map_scan),
261
+ CryonirspTag.scan_step(scan_step),
262
+ ],
263
+ decoder=fits_access_decoder,
264
+ fits_access_class=CryonirspL1FitsAccess,
265
+ )
266
+ )
267
+ return calibrated_frame.data
268
+
269
+ def get_integrated_calibrated_frame(
270
+ self, map_scan: int, meas_num: int, stokes_state: str
271
+ ) -> np.ndarray:
272
+ """Retrieve a frame that has been integrated across scan steps."""
273
+ data = self.get_spectral_calibrated_frame(
274
+ map_scan=map_scan, meas_num=meas_num, stokes_state=stokes_state
275
+ )
276
+ integrated_data = self.integrate_spectral_frame(data)
277
+ integrated_arrays = [integrated_data]
278
+ for scan_step in range(2, self.constants.num_scan_steps + 1):
279
+ calibrated_frame = next(
280
+ self.read(
281
+ tags=[
282
+ CryonirspTag.frame(),
283
+ CryonirspTag.calibrated(),
284
+ CryonirspTag.stokes(stokes_state),
285
+ CryonirspTag.meas_num(meas_num),
286
+ CryonirspTag.map_scan(map_scan),
287
+ CryonirspTag.scan_step(scan_step),
288
+ ],
289
+ decoder=fits_access_decoder,
290
+ fits_access_class=CryonirspL1FitsAccess,
291
+ )
292
+ )
293
+ integrated_data = self.integrate_spectral_frame(calibrated_frame.data)
294
+ integrated_arrays.append(integrated_data)
295
+ full_frame = np.vstack(integrated_arrays)
296
+ return full_frame
297
+
298
+ @staticmethod
299
+ def integrate_spectral_frame(data: np.ndarray) -> np.ndarray:
300
+ """Integrate spectral frame."""
301
+ wavelength_integrated_data = np.sum(np.abs(data), axis=1)
302
+ return wavelength_integrated_data
@@ -0,0 +1 @@
1
+ """Init."""
@@ -0,0 +1,52 @@
1
+ """Mixin to support extracting the desired beam from an input image on-the-fly."""
2
+ from functools import cached_property
3
+
4
+ import numpy as np
5
+
6
+
7
+ class BeamAccessMixin:
8
+ """Mixin that supports extracting the desired beam from an input image on-the-fly."""
9
+
10
+ def beam_access_get_beam(self, array: np.ndarray, beam: int) -> np.ndarray:
11
+ """
12
+ Extract a single beam array from a dual-beam array.
13
+
14
+ Parameters
15
+ ----------
16
+ array
17
+ The input dual-beam array
18
+ beam
19
+ The desired beam to extract
20
+
21
+ Returns
22
+ -------
23
+ An ndarray containing the extracted beam
24
+ """
25
+ boundaries = self.beam_boundaries[beam]
26
+ spatial_min, spatial_max, spectral_min, spectral_max = boundaries
27
+
28
+ if (
29
+ spatial_min < 0
30
+ or spatial_max > array.shape[0]
31
+ or spectral_min < 0
32
+ or spectral_max > array.shape[1]
33
+ ):
34
+ raise IndexError(
35
+ f"beam_access_get_boundaries exceed array bounds: {boundaries = }, {array.shape = }."
36
+ )
37
+
38
+ return np.copy(array[spatial_min:spatial_max, spectral_min:spectral_max])
39
+
40
+ @cached_property
41
+ def beam_boundaries(self) -> dict[int, np.ndarray]:
42
+ """
43
+ Load the beam boundaries from their respective files and return as a boundary dict.
44
+
45
+ Returns
46
+ -------
47
+ beam_boundary dict
48
+ """
49
+ boundaries = dict()
50
+ for beam in range(1, self.constants.num_beams + 1):
51
+ boundaries[beam] = self.intermediate_frame_load_beam_boundaries(beam=beam)
52
+ return boundaries
@@ -0,0 +1,177 @@
1
+ """Helper for CRYO-Nirsp array corrections."""
2
+ from collections.abc import Generator
3
+ from collections.abc import Iterable
4
+
5
+ import numpy as np
6
+ import scipy.ndimage as spnd
7
+ from dkist_processing_math.transform import affine_transform_arrays
8
+ from dkist_processing_math.transform import rotate_arrays_about_point
9
+
10
+
11
+ class CorrectionsMixin:
12
+ """Mixin to provide support for various array corrections used by the workflow tasks."""
13
+
14
+ @staticmethod
15
+ def corrections_correct_geometry(
16
+ arrays: Iterable[np.ndarray] | np.ndarray,
17
+ shift: np.ndarray = np.zeros(2),
18
+ angle: float = 0.0,
19
+ ) -> Generator[np.ndarray, None, None]:
20
+ """
21
+ Shift and then rotate data.
22
+
23
+ It applies the inverse of the given shift and angle.
24
+
25
+ Parameters
26
+ ----------
27
+ arrays
28
+ 2D array(s) containing the data for the un-shifted beam
29
+
30
+ shift
31
+ The measured shift offset in the spectral dimension
32
+ between beams or between modulator states in a single beam.
33
+
34
+ angle
35
+ The angle between the slit and pixel axes.
36
+
37
+ Returns
38
+ -------
39
+ Generator
40
+ 2D array(s) containing the data of the rotated and shifted beam
41
+ """
42
+ arrays = [arrays] if isinstance(arrays, np.ndarray) else arrays
43
+ for array in arrays:
44
+ # Need to recast type to fix endianness issue caused by `fits`
45
+ array = array.astype(np.float64)
46
+ array[np.where(array == np.inf)] = np.max(array[np.isfinite(array)])
47
+ array[np.where(array == -np.inf)] = np.min(array[np.isfinite(array)])
48
+ array[np.isnan(array)] = np.nanmedian(array)
49
+ translated = affine_transform_arrays(array, translation=-shift, mode="edge", order=1)
50
+ # rotate_arrays_about_point rotates the wrong way, so no negative sign here
51
+ yield next(rotate_arrays_about_point(translated, angle=angle, mode="edge", order=1))
52
+
53
+ @staticmethod
54
+ def corrections_distort_geometry(
55
+ arrays: Iterable[np.ndarray] | np.ndarray,
56
+ shift: np.ndarray = np.zeros(2),
57
+ angle: float = 0.0,
58
+ ) -> Generator[np.ndarray, None, None]:
59
+ """
60
+ Rotate and then shift data.
61
+
62
+ It applies the inverse of the given shift and angle.
63
+
64
+ Parameters
65
+ ----------
66
+ arrays
67
+ 2D array(s) containing the data for the un-shifted beam
68
+
69
+ shift
70
+ The measured shift offset in the spectral dimension
71
+ between beams or between modulator states in a single beam.
72
+
73
+ angle
74
+ The angle between the slit and pixel axes.
75
+
76
+ Returns
77
+ -------
78
+ Generator
79
+ 2D array(s) containing the data of the rotated and shifted beam
80
+ """
81
+ arrays = [arrays] if isinstance(arrays, np.ndarray) else arrays
82
+ for array in arrays:
83
+ # Need to recast type to fix endianness issue caused by `fits`
84
+ array = array.astype(np.float64)
85
+ array[np.where(array == np.inf)] = np.max(array[np.isfinite(array)])
86
+ array[np.where(array == -np.inf)] = np.min(array[np.isfinite(array)])
87
+ array[np.isnan(array)] = np.nanmedian(array)
88
+ # rotate_arrays_about_point rotates the wrong way, so no negative sign here
89
+ rotated = rotate_arrays_about_point(array, angle=angle, mode="edge")
90
+ yield next(affine_transform_arrays(rotated, translation=-shift, mode="edge"))
91
+
92
+ @staticmethod
93
+ def corrections_remove_spec_shifts(
94
+ arrays: Iterable[np.ndarray] | np.ndarray, spec_shift: np.ndarray
95
+ ) -> Generator[np.ndarray, None, None]:
96
+ """
97
+ Remove spectral curvature.
98
+
99
+ This is a pretty simple function that simply undoes the computed spectral shifts.
100
+
101
+ Parameters
102
+ ----------
103
+ arrays
104
+ 2D array(s) containing the data for the un-distorted beam
105
+
106
+ spec_shift : np.ndarray
107
+ Array with shape (X), where X is the number of pixels in the spatial dimension.
108
+ This dimension gives the spectral shift.
109
+
110
+ Returns
111
+ -------
112
+ Generator
113
+ 2D array(s) containing the data of the corrected beam
114
+
115
+ """
116
+ arrays = [arrays] if isinstance(arrays, np.ndarray) else arrays
117
+ for array in arrays:
118
+ numx = array.shape[0]
119
+ array_output = np.zeros(array.shape)
120
+ for j in range(numx):
121
+ array_output[j, :] = spnd.shift(array[j, :], -spec_shift[j], mode="nearest")
122
+ yield array_output
123
+
124
+ def corrections_correct_bad_pixels(
125
+ self,
126
+ array_to_fix: np.ndarray,
127
+ bad_pixel_map: np.ndarray,
128
+ ) -> np.ndarray:
129
+ """
130
+ Correct bad pixels in an array using a median filter.
131
+
132
+ Corrects only the bad pixels identified by the bad pixel map.
133
+
134
+ If enough pixels are found to suggest readout errors (> a given fraction of the array),
135
+ a global replacement value is used instead.
136
+
137
+ Parameters
138
+ ----------
139
+ array_to_fix
140
+ The array to be corrected
141
+
142
+ bad_pixel_map
143
+ The bad_pixel_map to use for the correction
144
+ An array of zeros with bad pixel locations set to 1
145
+
146
+ Returns
147
+ -------
148
+ ndarray
149
+ The corrected array
150
+ """
151
+ bad_pixel_fraction = np.sum(bad_pixel_map) / bad_pixel_map.size
152
+ if bad_pixel_fraction > self.parameters.corrections_bad_pixel_fraction_threshold:
153
+ global_median = np.nanmedian(array_to_fix)
154
+ array_to_fix[bad_pixel_map == 1] = global_median
155
+ return array_to_fix
156
+
157
+ kernel_size = self.parameters.corrections_bad_pixel_median_filter_size
158
+ half_kernel_size = kernel_size // 2
159
+
160
+ masked_array = np.ma.array(array_to_fix, mask=bad_pixel_map)
161
+
162
+ y_locs, x_locs = np.nonzero(bad_pixel_map)
163
+ num_y = bad_pixel_map.shape[0]
164
+ num_x = bad_pixel_map.shape[1]
165
+ for x, y in zip(x_locs, y_locs):
166
+ y_slice = slice(max(y - half_kernel_size, 0), min(y + half_kernel_size + 1, num_y))
167
+ x_slice = slice(max(x - half_kernel_size, 0), min(x + half_kernel_size + 1, num_x))
168
+
169
+ if self.constants.arm_id == "SP":
170
+ # Only compute median in spatial dimension so we don't blur things spectrally
171
+ med = np.ma.median(masked_array[y_slice, x])
172
+ else:
173
+ med = np.ma.median(masked_array[y_slice, x_slice])
174
+
175
+ masked_array[y, x] = med
176
+
177
+ return masked_array.data