dkist-processing-visp 3.3.0__py3-none-any.whl → 5.1.1__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 (71) hide show
  1. dkist_processing_visp/__init__.py +1 -0
  2. dkist_processing_visp/config.py +1 -0
  3. dkist_processing_visp/models/constants.py +52 -21
  4. dkist_processing_visp/models/fits_access.py +20 -0
  5. dkist_processing_visp/models/metric_code.py +10 -0
  6. dkist_processing_visp/models/parameters.py +129 -19
  7. dkist_processing_visp/models/tags.py +1 -0
  8. dkist_processing_visp/models/task_name.py +1 -0
  9. dkist_processing_visp/parsers/map_repeats.py +1 -0
  10. dkist_processing_visp/parsers/modulator_states.py +1 -0
  11. dkist_processing_visp/parsers/polarimeter_mode.py +3 -1
  12. dkist_processing_visp/parsers/raster_step.py +4 -1
  13. dkist_processing_visp/parsers/spectrograph_configuration.py +75 -0
  14. dkist_processing_visp/parsers/time.py +15 -7
  15. dkist_processing_visp/parsers/visp_l0_fits_access.py +19 -8
  16. dkist_processing_visp/parsers/visp_l1_fits_access.py +1 -0
  17. dkist_processing_visp/tasks/__init__.py +1 -0
  18. dkist_processing_visp/tasks/assemble_movie.py +1 -0
  19. dkist_processing_visp/tasks/background_light.py +2 -1
  20. dkist_processing_visp/tasks/dark.py +5 -4
  21. dkist_processing_visp/tasks/geometric.py +132 -20
  22. dkist_processing_visp/tasks/instrument_polarization.py +13 -12
  23. dkist_processing_visp/tasks/l1_output_data.py +203 -0
  24. dkist_processing_visp/tasks/lamp.py +53 -93
  25. dkist_processing_visp/tasks/make_movie_frames.py +8 -6
  26. dkist_processing_visp/tasks/mixin/beam_access.py +1 -0
  27. dkist_processing_visp/tasks/mixin/corrections.py +54 -4
  28. dkist_processing_visp/tasks/mixin/downsample.py +1 -0
  29. dkist_processing_visp/tasks/parse.py +34 -4
  30. dkist_processing_visp/tasks/quality_metrics.py +5 -4
  31. dkist_processing_visp/tasks/science.py +126 -46
  32. dkist_processing_visp/tasks/solar.py +896 -456
  33. dkist_processing_visp/tasks/visp_base.py +2 -0
  34. dkist_processing_visp/tasks/write_l1.py +25 -5
  35. dkist_processing_visp/tests/conftest.py +99 -35
  36. dkist_processing_visp/tests/header_models.py +92 -20
  37. dkist_processing_visp/tests/local_trial_workflows/l0_cals_only.py +4 -23
  38. dkist_processing_visp/tests/local_trial_workflows/l0_polcals_as_science.py +421 -0
  39. dkist_processing_visp/tests/local_trial_workflows/l0_solar_gain_as_science.py +10 -29
  40. dkist_processing_visp/tests/local_trial_workflows/l0_to_l1.py +1 -21
  41. dkist_processing_visp/tests/local_trial_workflows/local_trial_helpers.py +98 -14
  42. dkist_processing_visp/tests/test_assemble_movie.py +2 -3
  43. dkist_processing_visp/tests/test_assemble_quality.py +89 -4
  44. dkist_processing_visp/tests/test_background_light.py +8 -5
  45. dkist_processing_visp/tests/test_dark.py +4 -3
  46. dkist_processing_visp/tests/test_fits_access.py +43 -0
  47. dkist_processing_visp/tests/test_geometric.py +45 -4
  48. dkist_processing_visp/tests/test_instrument_polarization.py +4 -3
  49. dkist_processing_visp/tests/test_lamp.py +22 -26
  50. dkist_processing_visp/tests/test_make_movie_frames.py +4 -4
  51. dkist_processing_visp/tests/test_map_repeats.py +3 -1
  52. dkist_processing_visp/tests/test_parameters.py +122 -21
  53. dkist_processing_visp/tests/test_parse.py +98 -14
  54. dkist_processing_visp/tests/test_quality.py +2 -3
  55. dkist_processing_visp/tests/test_science.py +113 -15
  56. dkist_processing_visp/tests/test_solar.py +318 -99
  57. dkist_processing_visp/tests/test_visp_constants.py +36 -8
  58. dkist_processing_visp/tests/test_workflows.py +1 -0
  59. dkist_processing_visp/tests/test_write_l1.py +17 -3
  60. dkist_processing_visp/workflows/__init__.py +1 -0
  61. dkist_processing_visp/workflows/l0_processing.py +8 -2
  62. dkist_processing_visp/workflows/trial_workflows.py +8 -2
  63. dkist_processing_visp-5.1.1.dist-info/METADATA +552 -0
  64. dkist_processing_visp-5.1.1.dist-info/RECORD +94 -0
  65. docs/conf.py +5 -1
  66. docs/gain_correction.rst +50 -42
  67. dkist_processing_visp/tasks/mixin/line_zones.py +0 -115
  68. dkist_processing_visp-3.3.0.dist-info/METADATA +0 -459
  69. dkist_processing_visp-3.3.0.dist-info/RECORD +0 -90
  70. {dkist_processing_visp-3.3.0.dist-info → dkist_processing_visp-5.1.1.dist-info}/WHEEL +0 -0
  71. {dkist_processing_visp-3.3.0.dist-info → dkist_processing_visp-5.1.1.dist-info}/top_level.txt +0 -0
@@ -1,4 +1,5 @@
1
1
  """ViSP science calibration task. See :doc:`this page </science_calibration>` for more information."""
2
+
2
3
  from collections import defaultdict
3
4
  from dataclasses import dataclass
4
5
  from functools import cached_property
@@ -11,6 +12,7 @@ from astropy.time import TimeDelta
11
12
  from dkist_processing_common.codecs.fits import fits_access_decoder
12
13
  from dkist_processing_common.codecs.fits import fits_array_decoder
13
14
  from dkist_processing_common.codecs.fits import fits_hdulist_encoder
15
+ from dkist_processing_common.models.fits_access import MetadataKey
14
16
  from dkist_processing_common.models.task_name import TaskName
15
17
  from dkist_processing_common.tasks.mixin.quality import QualityMixin
16
18
  from dkist_processing_math.arithmetic import divide_arrays_by_array
@@ -20,6 +22,7 @@ from dkist_processing_math.statistics import average_numpy_arrays
20
22
  from dkist_processing_pac.optics.telescope import Telescope
21
23
  from dkist_service_configuration.logging import logger
22
24
 
25
+ from dkist_processing_visp.models.fits_access import VispMetadataKey
23
26
  from dkist_processing_visp.models.tags import VispTag
24
27
  from dkist_processing_visp.parsers.visp_l0_fits_access import VispL0FitsAccess
25
28
  from dkist_processing_visp.tasks.mixin.beam_access import BeamAccessMixin
@@ -48,6 +51,9 @@ class CalibrationCollection:
48
51
 
49
52
  This is done by considering that state offset values computed by the GeometricCalibration task. Any sub-pixel
50
53
  overlaps are rounded to the next integer that still guarantees overlap.
54
+
55
+ When "start pixels" are mentioned, those are pixels being counted from zero on a given axis in the positive direction.
56
+ When "end pixels" are mentioned, those are pixels being counted from the end of a given axis in the negative direction.
51
57
  """
52
58
  logger.info("Computing beam overlap slices")
53
59
  # This will be a flat list of (x, y) pairs for all modstates and beams
@@ -60,25 +66,33 @@ class CalibrationCollection:
60
66
  logger.info(f"All x shifts: {all_x_shifts}")
61
67
  logger.info(f"All y shifts: {all_y_shifts}")
62
68
 
63
- # The amount we need to "slice in" from the front of the array is simply the maximum positive shift
69
+ # The amount we need to "slice in" from the start of the array is equivalent to the absolute value of the most negative shift.
64
70
  # The call to `np.ceil` ensures that the integer rounding doesn't allow non-overlap regions to leak in.
65
- max_x = int(np.ceil(np.max(all_x_shifts)))
66
- max_y = int(np.ceil(np.max(all_y_shifts)))
71
+ start_pixels_to_slice_x = int(np.ceil(abs(np.min(all_x_shifts))))
72
+ start_pixels_to_slice_y = int(np.ceil(abs(np.min(all_y_shifts))))
67
73
 
68
- # The amount we need to "chop off" the end of the array is the most negative negative shift.
74
+ # The amount we need to "chop off" the end of the array is the most positive shift.
69
75
  #
70
76
  # Here we rely on the fact that the fiducial array's shift is *always* (0, 0)
71
- # (see `geometric.compute_modstate_offset`). Thus, if there are no negative shifts then the following lines
72
- # will result in None. This is required for slicing because array[x:0] is no good. So if the min is 0 then we
73
- # end up with array[x:None] which goes all the way to the end of the array.
77
+ # (see `geometric.compute_modstate_offset`). Thus, if there are no negative shifts then the following lines
78
+ # will result in None. This is required for slicing because array[x:0] is no good. So if the max is 0 then we
79
+ # end up with array[x:None] which goes all the way to the end of the array.
74
80
  #
75
- # The call to `np.floor` ensures that the integer rounding doesn't allow non-overlap regions to leak in.
76
- # (because more negative slices will cut out more data).
77
- min_x = int(np.floor(np.min(all_x_shifts))) or None
78
- min_y = int(np.floor(np.min(all_y_shifts))) or None
81
+ # The call to `np.ceil` ensures that the integer rounding doesn't allow non-overlap regions to leak in.
82
+ # (because more negative slices will cut out more data).
83
+ end_pixels_to_slice_x = int(np.ceil(np.max(all_x_shifts))) or None
84
+ end_pixels_to_slice_y = int(np.ceil(np.max(all_y_shifts))) or None
85
+
86
+ # As the pixels to remove from the end of axes is given as a positive number, we need to make it negative for slicing.
87
+ if end_pixels_to_slice_x is not None:
88
+ end_pixels_to_slice_x *= -1
89
+
90
+ if end_pixels_to_slice_y is not None:
91
+ end_pixels_to_slice_y *= -1
79
92
 
80
- x_slice = slice(max_x, min_x)
81
- y_slice = slice(max_y, min_y)
93
+ # Construct the slices
94
+ x_slice = slice(start_pixels_to_slice_x, end_pixels_to_slice_x)
95
+ y_slice = slice(start_pixels_to_slice_y, end_pixels_to_slice_y)
82
96
 
83
97
  return x_slice, y_slice
84
98
 
@@ -125,17 +139,17 @@ class ScienceCalibration(
125
139
  None
126
140
 
127
141
  """
128
- with self.apm_task_step("Loading calibration objects"):
142
+ with self.telemetry_span("Loading calibration objects"):
129
143
  calibrations = self.collect_calibration_objects()
130
144
 
131
- with self.apm_task_step(
145
+ with self.telemetry_span(
132
146
  f"Processing Science Frames for "
133
147
  f"{self.constants.num_map_scans} map scans and "
134
148
  f"{self.constants.num_raster_steps} raster steps"
135
149
  ):
136
150
  self.process_frames(calibrations=calibrations)
137
151
 
138
- with self.apm_processing_step("Computing and logging quality metrics"):
152
+ with self.telemetry_span("Computing and logging quality metrics"):
139
153
  no_of_raw_science_frames: int = self.scratch.count_all(
140
154
  tags=[
141
155
  VispTag.input(),
@@ -156,7 +170,7 @@ class ScienceCalibration(
156
170
  """
157
171
  dark_dict = defaultdict(dict)
158
172
  background_dict = dict()
159
- solar_dict = defaultdict(dict)
173
+ solar_dict = dict()
160
174
  angle_dict = dict()
161
175
  state_offset_dict = defaultdict(dict)
162
176
  spec_shift_dict = dict()
@@ -211,6 +225,18 @@ class ScienceCalibration(
211
225
  )
212
226
  )
213
227
 
228
+ # Solar
229
+ #######
230
+ solar_dict[VispTag.beam(beam)] = next(
231
+ self.read(
232
+ tags=[
233
+ VispTag.intermediate_frame(beam=beam),
234
+ VispTag.task_solar_gain(),
235
+ ],
236
+ decoder=fits_array_decoder,
237
+ )
238
+ )
239
+
214
240
  # Demod
215
241
  #######
216
242
  if self.constants.correct_for_polarization:
@@ -225,18 +251,6 @@ class ScienceCalibration(
225
251
  )
226
252
 
227
253
  for modstate in range(1, self.constants.num_modstates + 1):
228
- # Solar
229
- #######
230
- solar_dict[VispTag.beam(beam)][VispTag.modstate(modstate)] = next(
231
- self.read(
232
- tags=[
233
- VispTag.intermediate_frame(beam=beam, modstate=modstate),
234
- VispTag.task_solar_gain(),
235
- ],
236
- decoder=fits_array_decoder,
237
- )
238
- )
239
-
240
254
  # State Offset
241
255
  ##############
242
256
  state_offset_dict[VispTag.beam(beam)][VispTag.modstate(modstate)] = next(
@@ -274,9 +288,10 @@ class ScienceCalibration(
274
288
  for raster_step in range(0, self.constants.num_raster_steps):
275
289
  beam_storage = dict()
276
290
  header_storage = dict()
291
+ nan_storage = dict()
277
292
  for beam in range(1, self.constants.num_beams + 1):
278
293
  apm_str = f"{map_scan = }, {raster_step = }, and {beam = }"
279
- with self.apm_processing_step(f"Basic corrections for {apm_str}"):
294
+ with self.telemetry_span(f"Basic corrections for {apm_str}"):
280
295
  # Initialize array_stack and headers
281
296
  if self.constants.correct_for_polarization:
282
297
  logger.info(
@@ -285,6 +300,7 @@ class ScienceCalibration(
285
300
  (
286
301
  intermediate_array,
287
302
  intermediate_header,
303
+ nan_mask,
288
304
  ) = self.process_polarimetric_modstates(
289
305
  beam=beam,
290
306
  raster_step=raster_step,
@@ -296,7 +312,11 @@ class ScienceCalibration(
296
312
  logger.info(
297
313
  f"Processing spectrographic observe frames from {apm_str}"
298
314
  )
299
- intermediate_array, intermediate_header = self.correct_single_frame(
315
+ (
316
+ intermediate_array,
317
+ intermediate_header,
318
+ nan_mask,
319
+ ) = self.correct_single_frame(
300
320
  beam=beam,
301
321
  modstate=1,
302
322
  raster_step=raster_step,
@@ -307,20 +327,38 @@ class ScienceCalibration(
307
327
  intermediate_header = self.compute_date_keys(intermediate_header)
308
328
  beam_storage[VispTag.beam(beam)] = intermediate_array
309
329
  header_storage[VispTag.beam(beam)] = intermediate_header
330
+ nan_storage[VispTag.beam(beam)] = nan_mask
310
331
 
311
- with self.apm_processing_step("Combining beams"):
332
+ with self.telemetry_span("Combining beams"):
312
333
  calibrated = self.combine_beams(beam_storage, header_storage, calibrations)
313
334
 
314
335
  if self.constants.correct_for_polarization:
315
- with self.apm_processing_step("Correcting telescope polarization"):
336
+ with self.telemetry_span("Correcting telescope polarization"):
316
337
  calibrated = self.telescope_polarization_correction(calibrated)
317
338
 
339
+ with self.telemetry_span("Combining NaN masks from beams"):
340
+ cut_combined_nan_mask = self.combine_and_cut_nan_masks(
341
+ list(nan_storage.values()), calibrations
342
+ )
343
+
318
344
  # Save the final output files
319
- with self.apm_writing_step("Writing calibrated arrays"):
345
+ with self.telemetry_span("Writing calibrated arrays"):
320
346
  self.write_calibrated_array(
321
- calibrated, map_scan=map_scan, calibrations=calibrations
347
+ calibrated,
348
+ map_scan=map_scan,
349
+ calibrations=calibrations,
350
+ nan_mask=cut_combined_nan_mask,
322
351
  )
323
352
 
353
+ @staticmethod
354
+ def combine_and_cut_nan_masks(
355
+ nan_masks: list[np.ndarray], calibrations: CalibrationCollection
356
+ ) -> np.ndarray:
357
+ """Combine two NaN masks into one, cropping the result based on pre-calculated shifts."""
358
+ combined_nan_mask = np.logical_or.reduce(nan_masks)
359
+ x_slice, y_slice = calibrations.beams_overlap_slice
360
+ return combined_nan_mask[x_slice, y_slice]
361
+
324
362
  def process_polarimetric_modstates(
325
363
  self,
326
364
  beam: int,
@@ -328,7 +366,7 @@ class ScienceCalibration(
328
366
  map_scan: int,
329
367
  readout_exp_time: float,
330
368
  calibrations: CalibrationCollection,
331
- ) -> tuple[np.ndarray, fits.Header]:
369
+ ) -> tuple[np.ndarray, fits.Header, np.ndarray]:
332
370
  """
333
371
  Process a single polarimetric beam as much as is possible.
334
372
 
@@ -340,11 +378,12 @@ class ScienceCalibration(
340
378
  ].shape
341
379
  array_stack = np.zeros(array_shape + (self.constants.num_modstates,))
342
380
  header_stack = []
381
+ nan_mask_stack = np.zeros(array_shape + (self.constants.num_modstates,))
343
382
 
344
- with self.apm_processing_step(f"Correcting {self.constants.num_modstates} modstates"):
383
+ with self.telemetry_span(f"Correcting {self.constants.num_modstates} modstates"):
345
384
  for modstate in range(1, self.constants.num_modstates + 1):
346
385
  # Correct the arrays
347
- corrected_array, corrected_header = self.correct_single_frame(
386
+ corrected_array, corrected_header, nan_mask = self.correct_single_frame(
348
387
  beam=beam,
349
388
  modstate=modstate,
350
389
  raster_step=raster_step,
@@ -355,15 +394,17 @@ class ScienceCalibration(
355
394
  # Add this result to the 3D stack
356
395
  array_stack[:, :, modstate - 1] = corrected_array
357
396
  header_stack.append(corrected_header)
397
+ nan_mask_stack[:, :, modstate - 1] = nan_mask
358
398
 
359
- with self.apm_processing_step("Applying instrument polarization correction"):
399
+ with self.telemetry_span("Applying instrument polarization correction"):
360
400
  intermediate_array = nd_left_matrix_multiply(
361
401
  vector_stack=array_stack,
362
402
  matrix_stack=calibrations.demod_matrices[VispTag.beam(beam)],
363
403
  )
364
404
  intermediate_header = self.compute_date_keys(header_stack)
365
405
 
366
- return intermediate_array, intermediate_header
406
+ # The modulator state NaN masks are stacked along axis=2 with axis=0 & 1 being the array axes of one modstate
407
+ return intermediate_array, intermediate_header, np.logical_or.reduce(nan_mask_stack, axis=2)
367
408
 
368
409
  def combine_beams(
369
410
  self,
@@ -437,6 +478,7 @@ class ScienceCalibration(
437
478
  calibrated_object: VispL0FitsAccess,
438
479
  map_scan: int,
439
480
  calibrations: CalibrationCollection,
481
+ nan_mask: np.ndarray,
440
482
  ) -> None:
441
483
  """
442
484
  Write out calibrated science frames.
@@ -455,6 +497,9 @@ class ScienceCalibration(
455
497
  calibrations
456
498
  Calibration collection
457
499
 
500
+ nan_mask
501
+ A mask containing the known areas where data does not exist for both beams
502
+
458
503
  Returns
459
504
  -------
460
505
  None
@@ -470,7 +515,8 @@ class ScienceCalibration(
470
515
  stokes_I_data = calibrated_object.data[:, :, 0]
471
516
  for i, stokes_param in enumerate(self.constants.stokes_params):
472
517
  stokes_data = calibrated_object.data[:, :, i]
473
- final_data = self.re_dummy_data(stokes_data)
518
+ nan_masked_data = np.where(nan_mask, np.nan, stokes_data)
519
+ final_data = self.re_dummy_data(nan_masked_data)
474
520
  pol_header = self.add_L1_pol_headers(final_header, stokes_data, stokes_I_data)
475
521
  self.write_cal_array(
476
522
  data=final_data,
@@ -480,7 +526,8 @@ class ScienceCalibration(
480
526
  map_scan=map_scan,
481
527
  )
482
528
  else: # Only write stokes I
483
- final_data = self.re_dummy_data(calibrated_object.data)
529
+ nan_masked_data = np.where(nan_mask, np.nan, calibrated_object.data)
530
+ final_data = self.re_dummy_data(nan_masked_data)
484
531
  self.write_cal_array(
485
532
  data=final_data,
486
533
  header=final_header,
@@ -497,7 +544,7 @@ class ScienceCalibration(
497
544
  map_scan: int,
498
545
  readout_exp_time: float,
499
546
  calibrations: CalibrationCollection,
500
- ) -> tuple[np.ndarray, fits.Header]:
547
+ ) -> tuple[np.ndarray, fits.Header, np.ndarray]:
501
548
  """
502
549
  Apply basic corrections to a single frame.
503
550
 
@@ -539,7 +586,7 @@ class ScienceCalibration(
539
586
  VispTag.readout_exp_time(readout_exp_time)
540
587
  ]
541
588
  background_array = calibrations.background[VispTag.beam(beam)]
542
- solar_gain_array = calibrations.solar_gain[VispTag.beam(beam)][VispTag.modstate(modstate)]
589
+ solar_gain_array = calibrations.solar_gain[VispTag.beam(beam)]
543
590
  angle = calibrations.angle[VispTag.beam(beam)]
544
591
  spec_shift = calibrations.spec_shift[VispTag.beam(beam)]
545
592
  state_offset = calibrations.state_offset[VispTag.beam(beam)][VispTag.modstate(modstate)]
@@ -592,7 +639,40 @@ class ScienceCalibration(
592
639
  self.corrections_remove_spec_geometry(geo_corrected_array, spec_shift)
593
640
  )
594
641
 
595
- return spectral_corrected_array, observe_object.header
642
+ nan_mask = self.generate_nan_mask(
643
+ solar_corrected_array=solar_corrected_array,
644
+ state_offset=state_offset,
645
+ angle=angle,
646
+ spec_shift=spec_shift,
647
+ )
648
+
649
+ return (
650
+ spectral_corrected_array,
651
+ observe_object.header,
652
+ nan_mask,
653
+ )
654
+
655
+ def generate_nan_mask(
656
+ self,
657
+ solar_corrected_array: np.ndarray,
658
+ state_offset: np.ndarray,
659
+ angle: float,
660
+ spec_shift: np.ndarray,
661
+ ) -> np.ndarray:
662
+ """Calculate the NaN mask through geometric correction to be applied to the final L1 arrays."""
663
+ # Using a bi-cubic polynomial (order = 3) best converges to the desired result in the underlying fits
664
+ geo_corrected_with_nan = next(
665
+ self.corrections_correct_geometry(
666
+ solar_corrected_array, state_offset, angle, mode="constant", order=3, cval=np.nan
667
+ )
668
+ )
669
+ # Interpolating with nearest neighbor (order = 0) prevents NaN values from "taking over" the whole array
670
+ spectral_corrected_with_nan = next(
671
+ self.corrections_remove_spec_geometry(
672
+ geo_corrected_with_nan, spec_shift, cval=np.nan, order=0
673
+ )
674
+ )
675
+ return np.isnan(spectral_corrected_with_nan)
596
676
 
597
677
  def telescope_polarization_correction(
598
678
  self,
@@ -652,7 +732,7 @@ class ScienceCalibration(
652
732
  date_end = (Time(sorted_obj_list[-1].time_obs) + exp_time).isot
653
733
 
654
734
  header = sorted_obj_list[0].header
655
- header["DATE-BEG"] = date_beg
735
+ header[MetadataKey.time_obs] = date_beg
656
736
  header["DATE-END"] = date_end
657
737
 
658
738
  return header