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,511 @@
1
+ """Base frameworks for Cryonirsp science calibration."""
2
+ from abc import ABC
3
+ from abc import abstractmethod
4
+ from collections.abc import Iterable
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+ from astropy.io import fits
9
+ from astropy.time import Time
10
+ from astropy.time import TimeDelta
11
+ from dkist_processing_common.codecs.fits import fits_hdulist_encoder
12
+ from dkist_processing_common.models.task_name import TaskName
13
+ from dkist_processing_math.arithmetic import divide_arrays_by_array
14
+ from dkist_processing_math.arithmetic import subtract_array_from_arrays
15
+ from dkist_processing_math.statistics import average_numpy_arrays
16
+ from dkist_processing_pac.optics.telescope import Telescope
17
+ from dkist_service_configuration.logging import logger
18
+
19
+ from dkist_processing_cryonirsp.models.exposure_conditions import ExposureConditions
20
+ from dkist_processing_cryonirsp.models.tags import CryonirspTag
21
+ from dkist_processing_cryonirsp.parsers.cryonirsp_l0_fits_access import CryonirspL0FitsAccess
22
+ from dkist_processing_cryonirsp.tasks.cryonirsp_base import CryonirspTaskBase
23
+
24
+
25
+ @dataclass
26
+ class CalibrationCollection:
27
+ """Dataclass to hold all calibration objects and allow for easy, property-based access."""
28
+
29
+ dark: dict
30
+ solar_gain: dict
31
+ angle: dict | None
32
+ state_offset: dict | None
33
+ spec_shift: dict | None
34
+ demod_matrices: dict | None
35
+
36
+
37
+ class ScienceCalibrationBase(CryonirspTaskBase, ABC):
38
+ """
39
+ Task class for Cryonirsp science calibration of polarized and non-polarized data.
40
+
41
+ Parameters
42
+ ----------
43
+ recipe_run_id : int
44
+ id of the recipe run used to identify the workflow run this task is part of
45
+ workflow_name : str
46
+ name of the workflow to which this instance of the task belongs
47
+ workflow_version : str
48
+ version of the workflow to which this instance of the task belongs
49
+ """
50
+
51
+ record_provenance = True
52
+
53
+ def run(self):
54
+ """
55
+ Run Cryonirsp science calibration.
56
+
57
+ - Collect all calibration objects
58
+ - Calibrate and write all frames
59
+ - Record quality metrics
60
+
61
+ Returns
62
+ -------
63
+ None
64
+
65
+ """
66
+ with self.apm_task_step("Loading calibration objects"):
67
+ calibrations = self.collect_calibration_objects()
68
+
69
+ with self.apm_task_step(
70
+ f"Calibrating Science Frames for "
71
+ f"{self.constants.num_map_scans} map scans and "
72
+ f"{self.constants.num_scan_steps} scan steps"
73
+ ):
74
+ self.calibrate_and_write_frames(calibrations=calibrations)
75
+
76
+ with self.apm_processing_step("Computing and logging quality metrics"):
77
+ no_of_raw_science_frames: int = self.scratch.count_all(
78
+ tags=[
79
+ CryonirspTag.linearized(),
80
+ CryonirspTag.frame(),
81
+ CryonirspTag.task_observe(),
82
+ ],
83
+ )
84
+
85
+ self.quality_store_task_type_counts(
86
+ task_type=TaskName.observe.value, total_frames=no_of_raw_science_frames
87
+ )
88
+
89
+ @abstractmethod
90
+ def calibrate_and_write_frames(self, calibrations: CalibrationCollection):
91
+ """Fully calibrate science data and tag the results with CALIBRATED."""
92
+ pass
93
+
94
+ @abstractmethod
95
+ def collect_calibration_objects(self) -> CalibrationCollection:
96
+ """
97
+ Abstract method to be implemented in subclass.
98
+
99
+ Collect *all* calibration for all modstates, and exposure times.
100
+
101
+ Doing this once here prevents lots of reads as we reduce the science data.
102
+ """
103
+ pass
104
+
105
+ def apply_basic_corrections(
106
+ self,
107
+ beam: int,
108
+ modstate: int,
109
+ meas_num: int,
110
+ scan_step: int,
111
+ map_scan: int,
112
+ exposure_conditions: ExposureConditions,
113
+ calibrations: CalibrationCollection,
114
+ ) -> tuple[np.ndarray, fits.Header]:
115
+ """
116
+ Apply basic corrections to a single frame.
117
+
118
+ Generally the algorithm is:
119
+ 1. Dark correct the array
120
+ 2. Solar Gain correct the array
121
+
122
+ Parameters
123
+ ----------
124
+ modstate
125
+ The modulator state for this single step
126
+ scan_step
127
+ The slit step for this single step
128
+ map_scan
129
+ The current map scan
130
+ exposure_conditions
131
+ The exposure conditions for this single step
132
+ calibrations
133
+ Collection of all calibration objects
134
+
135
+ Returns
136
+ -------
137
+ Corrected array, header
138
+ """
139
+ # Extract calibrations
140
+ dark_array = calibrations.dark[CryonirspTag.beam(beam)][
141
+ CryonirspTag.exposure_conditions(exposure_conditions)
142
+ ]
143
+ gain_array = calibrations.solar_gain[CryonirspTag.beam(beam)]
144
+
145
+ # Grab the input observe frame(s)
146
+ observe_object_list = list(
147
+ self.linearized_frame_observe_fits_access_generator(
148
+ beam=beam,
149
+ map_scan=map_scan,
150
+ scan_step=scan_step,
151
+ meas_num=meas_num,
152
+ modstate=modstate,
153
+ exposure_conditions=exposure_conditions,
154
+ )
155
+ )
156
+ # There can be more than 1 frame if there are sub-repeats
157
+ observe_fits_access = sorted(
158
+ [item for item in observe_object_list],
159
+ key=lambda x: x.time_obs,
160
+ )
161
+ observe_arrays = [item.data for item in observe_fits_access]
162
+ observe_headers = [item.header for item in observe_fits_access]
163
+
164
+ # Average over sub-repeats, if there are any
165
+ avg_observe_array = average_numpy_arrays(observe_arrays)
166
+ # Get the header for this frame
167
+ observe_header = observe_headers[0]
168
+
169
+ # Dark correction
170
+ dark_corrected_array = next(subtract_array_from_arrays(avg_observe_array, dark_array))
171
+
172
+ # Bad pixel correction
173
+ bad_pixel_map = self.intermediate_frame_load_bad_pixel_map(beam=beam)
174
+ bad_pixel_corrected_array = self.corrections_correct_bad_pixels(
175
+ dark_corrected_array, bad_pixel_map
176
+ )
177
+
178
+ # Gain correction
179
+ gain_corrected_array = next(divide_arrays_by_array(bad_pixel_corrected_array, gain_array))
180
+
181
+ return gain_corrected_array, observe_header
182
+
183
+ def correct_and_demodulate(
184
+ self,
185
+ beam: int,
186
+ meas_num: int,
187
+ scan_step: int,
188
+ map_scan: int,
189
+ exposure_conditions: ExposureConditions,
190
+ calibrations: CalibrationCollection,
191
+ ) -> tuple[np.ndarray, fits.Header]:
192
+ """
193
+ Process and demodulate a single collection of modulation state data.
194
+
195
+ - Apply dark and gain corrections
196
+ - Demodulate
197
+ """
198
+ # Create the 3D stack of corrected modulated arrays
199
+ array_shape = calibrations.dark[CryonirspTag.beam(1)][
200
+ CryonirspTag.exposure_conditions(exposure_conditions)
201
+ ].shape
202
+ array_stack = np.zeros(array_shape + (self.constants.num_modstates,))
203
+ header_stack = []
204
+
205
+ with self.apm_processing_step(f"Correcting {self.constants.num_modstates} modstates"):
206
+ for modstate in range(1, self.constants.num_modstates + 1):
207
+ # Correct the arrays
208
+ corrected_array, corrected_header = self.apply_basic_corrections(
209
+ beam=beam,
210
+ modstate=modstate,
211
+ meas_num=meas_num,
212
+ scan_step=scan_step,
213
+ map_scan=map_scan,
214
+ exposure_conditions=exposure_conditions,
215
+ calibrations=calibrations,
216
+ )
217
+ # Add this result to the 3D stack
218
+ array_stack[:, :, modstate - 1] = corrected_array
219
+ header_stack.append(corrected_header)
220
+
221
+ with self.apm_processing_step("Applying instrument polarization correction"):
222
+ intermediate_array = self.polarization_correction(
223
+ array_stack, calibrations.demod_matrices[CryonirspTag.beam(beam)]
224
+ )
225
+ intermediate_header = self.compute_date_keys(header_stack)
226
+
227
+ return intermediate_array, intermediate_header
228
+
229
+ @staticmethod
230
+ def polarization_correction(array_stack: np.ndarray, demod_matrices: np.ndarray) -> np.ndarray:
231
+ """
232
+ Apply a polarization correction to an array by multiplying the array stack by the demod matrices.
233
+
234
+ Parameters
235
+ ----------
236
+ array_stack : np.ndarray
237
+ (x, y, M) stack of corrected arrays with M modulation states
238
+
239
+ demod_matrices : np.ndarray
240
+ (x, y, 4, M) stack of demodulation matrices with 4 stokes planes and M modulation states
241
+
242
+
243
+ Returns
244
+ -------
245
+ np.ndarray
246
+ (x, y, 4) ndarray with the planes being IQUV
247
+ """
248
+ demodulated_array = np.sum(demod_matrices * array_stack[:, :, None, :], axis=3)
249
+ return demodulated_array
250
+
251
+ def telescope_polarization_correction(
252
+ self,
253
+ inst_demod_obj: CryonirspL0FitsAccess,
254
+ ) -> CryonirspL0FitsAccess:
255
+ """
256
+ Apply a telescope polarization correction.
257
+
258
+ Parameters
259
+ ----------
260
+ inst_demod_obj
261
+ A demodulated, beam averaged frame
262
+
263
+ Returns
264
+ -------
265
+ FitsAccess object with telescope corrections applied
266
+ """
267
+ tm = Telescope.from_fits_access(inst_demod_obj)
268
+ mueller_matrix = tm.generate_inverse_telescope_model(
269
+ M12=True, rotate_to_fixed_SDO_HINODE_polarized_frame=True, swap_UV_signs=True
270
+ )
271
+ inst_demod_obj.data = self.polarization_correction(inst_demod_obj.data, mueller_matrix)
272
+ return inst_demod_obj
273
+
274
+ def write_calibrated_object(
275
+ self,
276
+ calibrated_object: CryonirspL0FitsAccess,
277
+ map_scan: int,
278
+ scan_step: int,
279
+ meas_num: int,
280
+ ) -> None:
281
+ """
282
+ Write out calibrated science frames.
283
+
284
+ For polarized data write out calibrated science frames for all 4 Stokes parameters.
285
+ For non-polarized data write out calibrated science frames for Stokes I only.
286
+
287
+ Parameters
288
+ ----------
289
+ calibrated_object
290
+ Corrected frames object
291
+
292
+ map_scan
293
+ The current map scan. Needed because it's not a header key
294
+
295
+ scan_step
296
+ The current scan step
297
+
298
+ meas_num
299
+ The current measurement number
300
+ """
301
+ final_header = self.update_calibrated_header(calibrated_object.header, map_scan=map_scan)
302
+ if self.constants.correct_for_polarization:
303
+ stokes_I_data = calibrated_object.data[:, :, 0]
304
+ for s, stokes_param in enumerate(self.constants.stokes_params):
305
+ stokes_data = calibrated_object.data[:, :, s]
306
+ final_data = self.re_dummy_data(stokes_data)
307
+ pol_header = self.add_L1_pol_headers(final_header, stokes_data, stokes_I_data)
308
+ self.write_calibrated_array(
309
+ data=final_data,
310
+ header=pol_header,
311
+ stokes=stokes_param,
312
+ meas_num=meas_num,
313
+ scan_step=scan_step,
314
+ map_scan=map_scan,
315
+ )
316
+
317
+ else:
318
+ final_data = self.re_dummy_data(calibrated_object.data[:, :, 0])
319
+ self.write_calibrated_array(
320
+ data=final_data,
321
+ header=final_header,
322
+ stokes="I",
323
+ meas_num=meas_num,
324
+ scan_step=scan_step,
325
+ map_scan=map_scan,
326
+ )
327
+
328
+ @staticmethod
329
+ def wrap_array_and_header_in_fits_access(
330
+ array: np.ndarray, header: fits.Header
331
+ ) -> CryonirspL0FitsAccess:
332
+ """Wrap input array and header in a CryonirspL0FitsAccess object."""
333
+ hdu = fits.ImageHDU(data=array, header=header)
334
+ obj = CryonirspL0FitsAccess(hdu=hdu, auto_squeeze=False)
335
+
336
+ return obj
337
+
338
+ @staticmethod
339
+ def add_stokes_dimension_to_intensity_only_array(array: np.ndarray) -> np.ndarray:
340
+ """
341
+ Add a length-1 dimension to the end of an array.
342
+
343
+ We do this so code that loops over the Stokes dimension still work with I-only data.
344
+ """
345
+ return array[..., None]
346
+
347
+ @staticmethod
348
+ def compute_date_keys(headers: Iterable[fits.Header] | fits.Header) -> fits.Header:
349
+ """
350
+ Generate correct DATE-??? header keys from a set of input headers.
351
+
352
+ Keys are computed thusly:
353
+ * DATE-BEG - The (Spec-0122) DATE-OBS of the earliest input header
354
+ * DATE-END - The (Spec-0122) DATE-OBS of the latest input header, plus the FPA exposure time
355
+
356
+ Returns
357
+ -------
358
+ fits.Header
359
+ A copy of the earliest header, but with correct DATE-??? keys
360
+ """
361
+ if isinstance(headers, fits.Header) or isinstance(
362
+ headers, fits.hdu.compressed.CompImageHeader
363
+ ):
364
+ headers = [headers]
365
+
366
+ sorted_obj_list = sorted(
367
+ [CryonirspL0FitsAccess.from_header(h) for h in headers], key=lambda x: Time(x.time_obs)
368
+ )
369
+ date_beg = sorted_obj_list[0].time_obs
370
+ exp_time = TimeDelta(sorted_obj_list[-1].fpa_exposure_time_ms / 1000.0, format="sec")
371
+ date_end = (Time(sorted_obj_list[-1].time_obs) + exp_time).isot
372
+
373
+ header = sorted_obj_list[0].header
374
+ header["DATE-BEG"] = date_beg
375
+ header["DATE-END"] = date_end
376
+
377
+ return header
378
+
379
+ def re_dummy_data(self, data: np.ndarray):
380
+ """
381
+ Add the dummy dimension that we have been secretly squeezing out during processing.
382
+
383
+ The dummy dimension is required because its corresponding WCS axis contains important information.
384
+
385
+ Parameters
386
+ ----------
387
+ data : np.ndarray
388
+ Corrected data
389
+ """
390
+ logger.info(f"Adding dummy WCS dimension to array with shape {data.shape}")
391
+ return data[None, :, :]
392
+
393
+ def update_calibrated_header(self, header: fits.Header, map_scan: int) -> fits.Header:
394
+ """
395
+ Update calibrated headers with any information gleaned during science calibration.
396
+
397
+ Right now all this does is put map scan values in the header.
398
+
399
+ Parameters
400
+ ----------
401
+ header
402
+ The header to update
403
+
404
+ map_scan
405
+ Current map scan
406
+
407
+ Returns
408
+ -------
409
+ fits.Header
410
+ """
411
+ # Correct the headers for the number of map_scans due to potential observation aborts
412
+ header["CNNMAPS"] = self.constants.num_map_scans
413
+ header["CNMAP"] = map_scan
414
+
415
+ return header
416
+
417
+ def add_L1_pol_headers(
418
+ self, input_header: fits.Header, stokes_data: np.ndarray, stokes_I_data: np.ndarray
419
+ ) -> fits.Header:
420
+ """Compute and add 214 header values specific to polarimetric datasets."""
421
+ # Probably not needed, but just to be safe
422
+ output_header = input_header.copy()
423
+
424
+ pol_noise = self.compute_polarimetric_noise(stokes_data, stokes_I_data)
425
+ pol_sensitivity = self.compute_polarimetric_sensitivity(stokes_I_data)
426
+ output_header["POL_NOIS"] = pol_noise
427
+ output_header["POL_SENS"] = pol_sensitivity
428
+
429
+ return output_header
430
+
431
+ def compute_polarimetric_noise(
432
+ self, stokes_data: np.ndarray, stokes_I_data: np.ndarray
433
+ ) -> float:
434
+ r"""
435
+ Compute the polarimetric noise for a single frame.
436
+
437
+ The polarimetric noise, :math:`N`, is defined as
438
+
439
+ .. math::
440
+
441
+ N = stddev(\frac{F_i}{F_I})
442
+
443
+ where :math:`F_i` is a full array of values for Stokes parameter :math:`i` (I, Q, U, V), and :math:`F_I` is the
444
+ full frame of Stokes-I. The stddev is computed across the entire frame.
445
+ """
446
+ return float(np.nanstd(stokes_data / stokes_I_data))
447
+
448
+ def compute_polarimetric_sensitivity(self, stokes_I_data: np.ndarray) -> float:
449
+ r"""
450
+ Compute the polarimetric sensitivity for a single frame.
451
+
452
+ The polarimetric sensitivity is the smallest signal that can be measured based on the values in the Stokes-I
453
+ frame. The sensitivity, :math:`S`, is computed as
454
+
455
+ .. math::
456
+
457
+ S = \frac{1}{\sqrt{\mathrm{max}(F_I)}}
458
+
459
+ where :math:`F_I` is the full frame of values for Stokes-I.
460
+ """
461
+ return float(1.0 / np.sqrt(np.nanmax(stokes_I_data)))
462
+
463
+ def write_calibrated_array(
464
+ self,
465
+ data: np.ndarray,
466
+ header: fits.Header,
467
+ stokes: str,
468
+ meas_num: int,
469
+ scan_step: int,
470
+ map_scan: int,
471
+ ) -> None:
472
+ """
473
+ Write out calibrated array.
474
+
475
+ Parameters
476
+ ----------
477
+ data : np.ndarray
478
+ calibrated data to write out
479
+
480
+ header : fits.Header
481
+ calibrated header to write out
482
+
483
+ stokes : str
484
+ Stokes parameter of this step. 'I', 'Q', 'U', or 'V'
485
+
486
+ meas_num: int
487
+ The current measurement number
488
+
489
+ scan_step : int
490
+ The slit step for this step
491
+
492
+ map_scan : int
493
+ The current map scan
494
+ """
495
+ tags = [
496
+ CryonirspTag.calibrated(),
497
+ CryonirspTag.frame(),
498
+ CryonirspTag.stokes(stokes),
499
+ CryonirspTag.meas_num(meas_num),
500
+ CryonirspTag.scan_step(scan_step),
501
+ CryonirspTag.map_scan(map_scan),
502
+ ]
503
+ hdul = fits.HDUList([fits.PrimaryHDU(header=header, data=data)])
504
+ self.write(
505
+ data=hdul,
506
+ tags=tags,
507
+ encoder=fits_hdulist_encoder,
508
+ )
509
+
510
+ filename = next(self.read(tags=tags))
511
+ logger.info(f"Wrote calibrated frame for {tags = } to {filename}")