dkist-processing-visp 3.6.2__py3-none-any.whl → 4.0.0__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.
@@ -2,7 +2,6 @@
2
2
 
3
3
  from enum import Enum
4
4
 
5
- from dkist_processing_common.models.constants import BudName
6
5
  from dkist_processing_common.models.constants import ConstantsBase
7
6
 
8
7
 
@@ -0,0 +1,16 @@
1
+ """ViSP control of FITS key names and values."""
2
+
3
+ from enum import StrEnum
4
+
5
+
6
+ class VispMetadataKey(StrEnum):
7
+ """Controlled list of names for FITS metadata header keys."""
8
+
9
+ raster_scan_step = "VSPSTP"
10
+ total_raster_steps = "VSPNSTP"
11
+ modulator_state = "VSPSTNUM"
12
+ number_of_modulator_states = "VSPNUMST"
13
+ polarimeter_mode = "VISP_006"
14
+ axis_1_type = "CTYPE1"
15
+ axis_2_type = "CTYPE2"
16
+ axis_3_type = "CTYPE3"
@@ -4,6 +4,7 @@ from dkist_processing_common.models.task_name import TaskName
4
4
  from dkist_processing_common.parsers.unique_bud import TaskUniqueBud
5
5
 
6
6
  from dkist_processing_visp.models.constants import VispBudName
7
+ from dkist_processing_visp.models.fits_access import VispMetadataKey
7
8
 
8
9
 
9
10
  class PolarimeterModeBud(TaskUniqueBud):
@@ -12,6 +13,6 @@ class PolarimeterModeBud(TaskUniqueBud):
12
13
  def __init__(self):
13
14
  super().__init__(
14
15
  constant_name=VispBudName.polarimeter_mode.value,
15
- metadata_key="polarimeter_mode",
16
+ metadata_key=VispMetadataKey.polarimeter_mode,
16
17
  ip_task_types=TaskName.observe.value,
17
18
  )
@@ -10,6 +10,7 @@ from dkist_processing_common.parsers.single_value_single_key_flower import (
10
10
  )
11
11
 
12
12
  from dkist_processing_visp.models.constants import VispBudName
13
+ from dkist_processing_visp.models.fits_access import VispMetadataKey
13
14
  from dkist_processing_visp.models.tags import VispStemName
14
15
  from dkist_processing_visp.parsers.visp_l0_fits_access import VispL0FitsAccess
15
16
 
@@ -69,7 +70,8 @@ class RasterScanStepFlower(SingleValueSingleKeyFlower):
69
70
 
70
71
  def __init__(self):
71
72
  super().__init__(
72
- tag_stem_name=VispStemName.raster_step.value, metadata_key="raster_scan_step"
73
+ tag_stem_name=VispStemName.raster_step.value,
74
+ metadata_key=VispMetadataKey.raster_scan_step,
73
75
  )
74
76
 
75
77
  def setter(self, fits_obj: VispL0FitsAccess):
@@ -3,6 +3,7 @@
3
3
  from pathlib import Path
4
4
  from typing import NamedTuple
5
5
 
6
+ from dkist_processing_common.models.fits_access import MetadataKey
6
7
  from dkist_processing_common.models.flower_pot import SpilledDirt
7
8
  from dkist_processing_common.models.flower_pot import Stem
8
9
  from dkist_processing_common.models.flower_pot import Thorn
@@ -18,7 +19,7 @@ class NonDarkNonPolcalTaskReadoutExpTimesBud(Stem):
18
19
 
19
20
  def __init__(self):
20
21
  super().__init__(stem_name=VispBudName.non_dark_task_readout_exp_times.value)
21
- self.metadata_key = "sensor_readout_exposure_time_ms"
22
+ self.metadata_key = MetadataKey.sensor_readout_exposure_time_ms.name
22
23
 
23
24
  def setter(self, fits_obj: VispL0FitsAccess) -> float | SpilledDirt:
24
25
  """
@@ -73,7 +74,7 @@ class DarkReadoutExpTimePickyBud(Stem):
73
74
 
74
75
  def __init__(self):
75
76
  super().__init__(stem_name=VispBudName.dark_readout_exp_time_picky_bud.value)
76
- self.metadata_key = "sensor_readout_exposure_time_ms"
77
+ self.metadata_key = MetadataKey.sensor_readout_exposure_time_ms.name
77
78
 
78
79
  def setter(self, fits_obj: VispL0FitsAccess) -> tuple:
79
80
  """
@@ -3,6 +3,8 @@
3
3
  from astropy.io import fits
4
4
  from dkist_processing_common.parsers.l0_fits_access import L0FitsAccess
5
5
 
6
+ from dkist_processing_visp.models.fits_access import VispMetadataKey
7
+
6
8
 
7
9
  class VispL0FitsAccess(L0FitsAccess):
8
10
  """
@@ -30,11 +32,13 @@ class VispL0FitsAccess(L0FitsAccess):
30
32
  ):
31
33
  super().__init__(hdu=hdu, name=name, auto_squeeze=auto_squeeze)
32
34
 
33
- self.number_of_modulator_states: int = self.header["VSPNUMST"]
34
- self.raster_scan_step: int = self.header["VSPSTP"]
35
- self.total_raster_steps: int = self.header["VSPNSTP"]
36
- self.modulator_state: int = self.header["VSPSTNUM"]
37
- self.polarimeter_mode: str = self.header["VISP_006"]
38
- self.axis_1_type: str = self.header["CTYPE1"]
39
- self.axis_2_type: str = self.header["CTYPE2"]
40
- self.axis_3_type: str = self.header["CTYPE3"]
35
+ self.number_of_modulator_states: int = self.header[
36
+ VispMetadataKey.number_of_modulator_states
37
+ ]
38
+ self.raster_scan_step: int = self.header[VispMetadataKey.raster_scan_step]
39
+ self.total_raster_steps: int = self.header[VispMetadataKey.total_raster_steps]
40
+ self.modulator_state: int = self.header[VispMetadataKey.modulator_state]
41
+ self.polarimeter_mode: str = self.header[VispMetadataKey.polarimeter_mode]
42
+ self.axis_1_type: str = self.header[VispMetadataKey.axis_1_type]
43
+ self.axis_2_type: str = self.header[VispMetadataKey.axis_2_type]
44
+ self.axis_3_type: str = self.header[VispMetadataKey.axis_3_type]
@@ -5,6 +5,7 @@ from astropy.io import fits
5
5
  from astropy.visualization import ZScaleInterval
6
6
  from dkist_processing_common.codecs.fits import fits_access_decoder
7
7
  from dkist_processing_common.codecs.fits import fits_array_encoder
8
+ from dkist_processing_common.models.fits_access import MetadataKey
8
9
  from dkist_service_configuration.logging import logger
9
10
 
10
11
  from dkist_processing_visp.models.tags import VispTag
@@ -78,7 +79,7 @@ class MakeVispMovieFrames(VispTaskBase):
78
79
  fits_access_class=VispL1FitsAccess,
79
80
  )
80
81
  )
81
- data = calibrated_frame.data
82
+ data = np.nan_to_num(calibrated_frame.data, nan=0)
82
83
  if self.constants.num_raster_steps == 1:
83
84
  logger.info(
84
85
  "Only a single raster step found. Making a spectral movie."
@@ -121,16 +122,16 @@ class MakeVispMovieFrames(VispTaskBase):
121
122
  f"There should only be one instrument value in the headers. "
122
123
  f"Found {len(instrument_set)}: {instrument_set=}"
123
124
  )
124
- header["INSTRUME"] = instrument_set.pop()
125
+ header[MetadataKey.instrument] = instrument_set.pop()
125
126
  # The timestamp of a movie frame will be the time of raster scan start
126
- header["DATE-BEG"] = time_obs[0]
127
+ header[MetadataKey.time_obs] = time_obs[0]
127
128
  # Make sure only one wavelength value was found
128
129
  if len(wavelength_set) != 1:
129
130
  raise ValueError(
130
131
  f"There should only be one wavelength value in the headers. "
131
132
  f"Found {len(wavelength_set)}: {wavelength_set=}"
132
133
  )
133
- header["LINEWAV"] = wavelength_set.pop()
134
+ header[MetadataKey.wavelength] = wavelength_set.pop()
134
135
  # Write the movie frame file to disk and tag it, normalizing across stokes intensities
135
136
  if is_polarized:
136
137
  i_norm = ZScaleInterval()(stokes_i_data)
@@ -17,6 +17,9 @@ class CorrectionsMixin:
17
17
  arrays: Iterable[np.ndarray] | np.ndarray,
18
18
  shift: np.ndarray = np.zeros(2),
19
19
  angle: float = 0.0,
20
+ mode: str = "edge",
21
+ order: int = 5,
22
+ cval: float = np.nan,
20
23
  ) -> Generator[np.ndarray, None, None]:
21
24
  """
22
25
  Shift and then rotate data.
@@ -35,6 +38,24 @@ class CorrectionsMixin:
35
38
  angle : float
36
39
  The angle (in radians) between slit hairlines and pixel axes.
37
40
 
41
+ mode : {'constant', 'edge', 'symmetric', 'reflect', 'wrap'}
42
+ Points outside the boundaries of the input are filled according
43
+ to the given mode. Modes match the behaviour of `numpy.pad`.
44
+
45
+ order : int
46
+ The order of interpolation. The order has to be in the range 0-5:
47
+ - 0: Nearest-neighbor
48
+ - 1: Bi-linear (default)
49
+ - 2: Bi-quadratic
50
+ - 3: Bi-cubic
51
+ - 4: Bi-quartic
52
+ - 5: Bi-quintic
53
+
54
+ cval : float
55
+ Used in conjunction with mode 'constant', the value outside
56
+ the image boundaries.
57
+
58
+
38
59
  Returns
39
60
  -------
40
61
  Generator
@@ -46,12 +67,21 @@ class CorrectionsMixin:
46
67
  array[np.where(array == np.inf)] = np.max(array[np.isfinite(array)])
47
68
  array[np.where(array == -np.inf)] = np.min(array[np.isfinite(array)])
48
69
  array[np.isnan(array)] = np.nanmedian(array)
49
- translated = affine_transform_arrays(array, translation=-shift, mode="reflect", order=5)
50
- yield next(rotate_arrays_about_point(translated, angle=-angle, mode="reflect", order=5))
70
+ translated = affine_transform_arrays(
71
+ array, translation=-shift, mode=mode, order=order, cval=cval
72
+ )
73
+ yield next(
74
+ rotate_arrays_about_point(
75
+ translated, angle=-angle, mode=mode, order=order, cval=cval
76
+ )
77
+ )
51
78
 
52
79
  @staticmethod
53
80
  def corrections_remove_spec_geometry(
54
- arrays: Iterable[np.ndarray] | np.ndarray, spec_shift: np.ndarray
81
+ arrays: Iterable[np.ndarray] | np.ndarray,
82
+ spec_shift: np.ndarray,
83
+ cval: float | None = None,
84
+ order: int = 3,
55
85
  ) -> Generator[np.ndarray, None, None]:
56
86
  """
57
87
  Remove spectral curvature.
@@ -67,6 +97,19 @@ class CorrectionsMixin:
67
97
  Array with shape (X), where X is the number of pixels in the spatial dimension.
68
98
  This dimension gives the spectral shift.
69
99
 
100
+ order : int
101
+ The order of interpolation. The order has to be in the range 0-5:
102
+ - 0: Nearest-neighbor
103
+ - 1: Bi-linear (default)
104
+ - 2: Bi-quadratic
105
+ - 3: Bi-cubic
106
+ - 4: Bi-quartic
107
+ - 5: Bi-quintic
108
+
109
+ cval : float
110
+ Used in conjunction with mode 'constant', the value outside
111
+ the image boundaries.
112
+
70
113
  Returns
71
114
  -------
72
115
  Generator
@@ -78,8 +121,14 @@ class CorrectionsMixin:
78
121
  numy = array.shape[1]
79
122
  array_output = np.zeros(array.shape)
80
123
  for j in range(numy):
124
+ if cval is None:
125
+ cval = np.nanmedian(array[:, j])
81
126
  array_output[:, j] = spnd.shift(
82
- array[:, j], -spec_shift[j], mode="constant", cval=np.nanmedian(array[:, j])
127
+ array[:, j],
128
+ -spec_shift[j],
129
+ mode="constant",
130
+ cval=cval,
131
+ order=order,
83
132
  )
84
133
  yield array_output
85
134
 
@@ -20,6 +20,7 @@ from dkist_processing_common.parsers.wavelength import ObserveWavelengthBud
20
20
  from dkist_processing_common.tasks import ParseL0InputDataBase
21
21
 
22
22
  from dkist_processing_visp.models.constants import VispBudName
23
+ from dkist_processing_visp.models.fits_access import VispMetadataKey
23
24
  from dkist_processing_visp.models.parameters import VispParsingParameters
24
25
  from dkist_processing_visp.parsers.map_repeats import MapScanFlower
25
26
  from dkist_processing_visp.parsers.map_repeats import NumMapScansBud
@@ -123,9 +124,18 @@ class ParseL0VispInputData(ParseL0InputDataBase):
123
124
  ip_task_types=TaskName.polcal.value,
124
125
  header_task_parsing_func=parse_header_ip_task_with_gains,
125
126
  ),
126
- UniqueBud(constant_name=VispBudName.axis_1_type.value, metadata_key="axis_1_type"),
127
- UniqueBud(constant_name=VispBudName.axis_2_type.value, metadata_key="axis_2_type"),
128
- UniqueBud(constant_name=VispBudName.axis_3_type.value, metadata_key="axis_3_type"),
127
+ UniqueBud(
128
+ constant_name=VispBudName.axis_1_type.value,
129
+ metadata_key=VispMetadataKey.axis_1_type,
130
+ ),
131
+ UniqueBud(
132
+ constant_name=VispBudName.axis_2_type.value,
133
+ metadata_key=VispMetadataKey.axis_2_type,
134
+ ),
135
+ UniqueBud(
136
+ constant_name=VispBudName.axis_3_type.value,
137
+ metadata_key=VispMetadataKey.axis_3_type,
138
+ ),
129
139
  ]
130
140
 
131
141
  @property
@@ -162,7 +162,7 @@ class VispL1QualityMetrics(VispTaskBase, QualityMixin):
162
162
  continue
163
163
 
164
164
  # compute sensitivity for this Stokes parameter
165
- data_list.append(np.std(stokes_frame.data) / stokesI_med)
165
+ data_list.append(np.nanstd(stokes_frame.data) / stokesI_med)
166
166
 
167
167
  all_datetimes.append(Time(np.mean(polarization_data.datetimes), format="mjd").isot)
168
168
  for target, source in zip(
@@ -12,6 +12,7 @@ from astropy.time import TimeDelta
12
12
  from dkist_processing_common.codecs.fits import fits_access_decoder
13
13
  from dkist_processing_common.codecs.fits import fits_array_decoder
14
14
  from dkist_processing_common.codecs.fits import fits_hdulist_encoder
15
+ from dkist_processing_common.models.fits_access import MetadataKey
15
16
  from dkist_processing_common.models.task_name import TaskName
16
17
  from dkist_processing_common.tasks.mixin.quality import QualityMixin
17
18
  from dkist_processing_math.arithmetic import divide_arrays_by_array
@@ -21,6 +22,7 @@ from dkist_processing_math.statistics import average_numpy_arrays
21
22
  from dkist_processing_pac.optics.telescope import Telescope
22
23
  from dkist_service_configuration.logging import logger
23
24
 
25
+ from dkist_processing_visp.models.fits_access import VispMetadataKey
24
26
  from dkist_processing_visp.models.tags import VispTag
25
27
  from dkist_processing_visp.parsers.visp_l0_fits_access import VispL0FitsAccess
26
28
  from dkist_processing_visp.tasks.mixin.beam_access import BeamAccessMixin
@@ -49,6 +51,9 @@ class CalibrationCollection:
49
51
 
50
52
  This is done by considering that state offset values computed by the GeometricCalibration task. Any sub-pixel
51
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.
52
57
  """
53
58
  logger.info("Computing beam overlap slices")
54
59
  # This will be a flat list of (x, y) pairs for all modstates and beams
@@ -61,25 +66,33 @@ class CalibrationCollection:
61
66
  logger.info(f"All x shifts: {all_x_shifts}")
62
67
  logger.info(f"All y shifts: {all_y_shifts}")
63
68
 
64
- # 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.
65
70
  # The call to `np.ceil` ensures that the integer rounding doesn't allow non-overlap regions to leak in.
66
- max_x = int(np.ceil(np.max(all_x_shifts)))
67
- 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))))
68
73
 
69
- # 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.
70
75
  #
71
76
  # Here we rely on the fact that the fiducial array's shift is *always* (0, 0)
72
- # (see `geometric.compute_modstate_offset`). Thus, if there are no negative shifts then the following lines
73
- # will result in None. This is required for slicing because array[x:0] is no good. So if the min is 0 then we
74
- # 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.
75
80
  #
76
- # The call to `np.floor` ensures that the integer rounding doesn't allow non-overlap regions to leak in.
77
- # (because more negative slices will cut out more data).
78
- min_x = int(np.floor(np.min(all_x_shifts))) or None
79
- 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
80
89
 
81
- x_slice = slice(max_x, min_x)
82
- y_slice = slice(max_y, min_y)
90
+ if end_pixels_to_slice_y is not None:
91
+ end_pixels_to_slice_y *= -1
92
+
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)
83
96
 
84
97
  return x_slice, y_slice
85
98
 
@@ -275,6 +288,7 @@ class ScienceCalibration(
275
288
  for raster_step in range(0, self.constants.num_raster_steps):
276
289
  beam_storage = dict()
277
290
  header_storage = dict()
291
+ nan_storage = dict()
278
292
  for beam in range(1, self.constants.num_beams + 1):
279
293
  apm_str = f"{map_scan = }, {raster_step = }, and {beam = }"
280
294
  with self.telemetry_span(f"Basic corrections for {apm_str}"):
@@ -286,6 +300,7 @@ class ScienceCalibration(
286
300
  (
287
301
  intermediate_array,
288
302
  intermediate_header,
303
+ nan_mask,
289
304
  ) = self.process_polarimetric_modstates(
290
305
  beam=beam,
291
306
  raster_step=raster_step,
@@ -297,7 +312,11 @@ class ScienceCalibration(
297
312
  logger.info(
298
313
  f"Processing spectrographic observe frames from {apm_str}"
299
314
  )
300
- intermediate_array, intermediate_header = self.correct_single_frame(
315
+ (
316
+ intermediate_array,
317
+ intermediate_header,
318
+ nan_mask,
319
+ ) = self.correct_single_frame(
301
320
  beam=beam,
302
321
  modstate=1,
303
322
  raster_step=raster_step,
@@ -308,6 +327,7 @@ class ScienceCalibration(
308
327
  intermediate_header = self.compute_date_keys(intermediate_header)
309
328
  beam_storage[VispTag.beam(beam)] = intermediate_array
310
329
  header_storage[VispTag.beam(beam)] = intermediate_header
330
+ nan_storage[VispTag.beam(beam)] = nan_mask
311
331
 
312
332
  with self.telemetry_span("Combining beams"):
313
333
  calibrated = self.combine_beams(beam_storage, header_storage, calibrations)
@@ -316,12 +336,29 @@ class ScienceCalibration(
316
336
  with self.telemetry_span("Correcting telescope polarization"):
317
337
  calibrated = self.telescope_polarization_correction(calibrated)
318
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
+
319
344
  # Save the final output files
320
345
  with self.telemetry_span("Writing calibrated arrays"):
321
346
  self.write_calibrated_array(
322
- calibrated, map_scan=map_scan, calibrations=calibrations
347
+ calibrated,
348
+ map_scan=map_scan,
349
+ calibrations=calibrations,
350
+ nan_mask=cut_combined_nan_mask,
323
351
  )
324
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
+
325
362
  def process_polarimetric_modstates(
326
363
  self,
327
364
  beam: int,
@@ -329,7 +366,7 @@ class ScienceCalibration(
329
366
  map_scan: int,
330
367
  readout_exp_time: float,
331
368
  calibrations: CalibrationCollection,
332
- ) -> tuple[np.ndarray, fits.Header]:
369
+ ) -> tuple[np.ndarray, fits.Header, np.ndarray]:
333
370
  """
334
371
  Process a single polarimetric beam as much as is possible.
335
372
 
@@ -341,11 +378,12 @@ class ScienceCalibration(
341
378
  ].shape
342
379
  array_stack = np.zeros(array_shape + (self.constants.num_modstates,))
343
380
  header_stack = []
381
+ nan_mask_stack = np.zeros(array_shape + (self.constants.num_modstates,))
344
382
 
345
383
  with self.telemetry_span(f"Correcting {self.constants.num_modstates} modstates"):
346
384
  for modstate in range(1, self.constants.num_modstates + 1):
347
385
  # Correct the arrays
348
- corrected_array, corrected_header = self.correct_single_frame(
386
+ corrected_array, corrected_header, nan_mask = self.correct_single_frame(
349
387
  beam=beam,
350
388
  modstate=modstate,
351
389
  raster_step=raster_step,
@@ -356,6 +394,7 @@ class ScienceCalibration(
356
394
  # Add this result to the 3D stack
357
395
  array_stack[:, :, modstate - 1] = corrected_array
358
396
  header_stack.append(corrected_header)
397
+ nan_mask_stack[:, :, modstate - 1] = nan_mask
359
398
 
360
399
  with self.telemetry_span("Applying instrument polarization correction"):
361
400
  intermediate_array = nd_left_matrix_multiply(
@@ -364,7 +403,8 @@ class ScienceCalibration(
364
403
  )
365
404
  intermediate_header = self.compute_date_keys(header_stack)
366
405
 
367
- 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)
368
408
 
369
409
  def combine_beams(
370
410
  self,
@@ -438,6 +478,7 @@ class ScienceCalibration(
438
478
  calibrated_object: VispL0FitsAccess,
439
479
  map_scan: int,
440
480
  calibrations: CalibrationCollection,
481
+ nan_mask: np.ndarray,
441
482
  ) -> None:
442
483
  """
443
484
  Write out calibrated science frames.
@@ -456,6 +497,9 @@ class ScienceCalibration(
456
497
  calibrations
457
498
  Calibration collection
458
499
 
500
+ nan_mask
501
+ A mask containing the known areas where data does not exist for both beams
502
+
459
503
  Returns
460
504
  -------
461
505
  None
@@ -471,7 +515,8 @@ class ScienceCalibration(
471
515
  stokes_I_data = calibrated_object.data[:, :, 0]
472
516
  for i, stokes_param in enumerate(self.constants.stokes_params):
473
517
  stokes_data = calibrated_object.data[:, :, i]
474
- 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)
475
520
  pol_header = self.add_L1_pol_headers(final_header, stokes_data, stokes_I_data)
476
521
  self.write_cal_array(
477
522
  data=final_data,
@@ -481,7 +526,8 @@ class ScienceCalibration(
481
526
  map_scan=map_scan,
482
527
  )
483
528
  else: # Only write stokes I
484
- 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)
485
531
  self.write_cal_array(
486
532
  data=final_data,
487
533
  header=final_header,
@@ -498,7 +544,7 @@ class ScienceCalibration(
498
544
  map_scan: int,
499
545
  readout_exp_time: float,
500
546
  calibrations: CalibrationCollection,
501
- ) -> tuple[np.ndarray, fits.Header]:
547
+ ) -> tuple[np.ndarray, fits.Header, np.ndarray]:
502
548
  """
503
549
  Apply basic corrections to a single frame.
504
550
 
@@ -593,7 +639,40 @@ class ScienceCalibration(
593
639
  self.corrections_remove_spec_geometry(geo_corrected_array, spec_shift)
594
640
  )
595
641
 
596
- 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)
597
676
 
598
677
  def telescope_polarization_correction(
599
678
  self,
@@ -653,7 +732,7 @@ class ScienceCalibration(
653
732
  date_end = (Time(sorted_obj_list[-1].time_obs) + exp_time).isot
654
733
 
655
734
  header = sorted_obj_list[0].header
656
- header["DATE-BEG"] = date_beg
735
+ header[MetadataKey.time_obs] = date_beg
657
736
  header["DATE-END"] = date_end
658
737
 
659
738
  return header
@@ -5,11 +5,13 @@ from typing import Literal
5
5
 
6
6
  import astropy.units as u
7
7
  from astropy.io import fits
8
+ from dkist_processing_common.models.fits_access import MetadataKey
8
9
  from dkist_processing_common.tasks import WriteL1Frame
9
10
  from dkist_processing_common.tasks.write_l1 import WavelengthRange
10
11
  from dkist_service_configuration.logging import logger
11
12
 
12
13
  from dkist_processing_visp.models.constants import VispConstants
14
+ from dkist_processing_visp.models.fits_access import VispMetadataKey
13
15
 
14
16
  cached_info_logger = cache(logger.info)
15
17
  __all__ = ["VispWriteL1Frame"]
@@ -55,7 +57,7 @@ class VispWriteL1Frame(WriteL1Frame):
55
57
  """
56
58
  # Correct the headers for the number of map and scan steps per map due to potential observation aborts
57
59
  header["VSPNMAPS"] = self.constants.num_map_scans
58
- header["VSPNSTP"] = self.constants.num_raster_steps
60
+ header[VispMetadataKey.total_raster_steps] = self.constants.num_raster_steps
59
61
 
60
62
  if stokes.upper() not in self.constants.stokes_params:
61
63
  raise ValueError("The stokes parameter must be one of I, Q, U, V")
@@ -92,7 +94,7 @@ class VispWriteL1Frame(WriteL1Frame):
92
94
  header[f"CNAME{i}"] = "helioprojective longitude"
93
95
  header[f"DUNIT{i}"] = header[f"CUNIT{i}"]
94
96
  # Current position in raster scan which counts from zero
95
- header[f"DINDEX{i}"] = header["VSPSTP"] + 1
97
+ header[f"DINDEX{i}"] = header[VispMetadataKey.raster_scan_step] + 1
96
98
  else:
97
99
  raise ValueError(
98
100
  f"Unexpected axis type. Expected ['HPLT-TAN', 'AWAV', 'HPLN-TAN']. Got {axis_type}"
@@ -207,5 +209,7 @@ class VispWriteL1Frame(WriteL1Frame):
207
209
  header["CADMIN"] = self.constants.minimum_cadence * self.constants.num_modstates
208
210
  header["CADMAX"] = self.constants.maximum_cadence * self.constants.num_modstates
209
211
  header["CADVAR"] = self.constants.variance_cadence * self.constants.num_modstates
210
- header["XPOSURE"] = header["XPOSURE"] * self.constants.num_modstates
212
+ header[MetadataKey.fpa_exposure_time_ms] = (
213
+ header[MetadataKey.fpa_exposure_time_ms] * self.constants.num_modstates
214
+ )
211
215
  return header
@@ -11,6 +11,7 @@ from typing import Literal
11
11
  from astropy.io import fits
12
12
  from dkist_processing_common.codecs.basemodel import basemodel_encoder
13
13
  from dkist_processing_common.manual import ManualProcessing
14
+ from dkist_processing_common.models.fits_access import MetadataKey
14
15
  from dkist_processing_common.models.input_dataset import InputDatasetPartDocumentList
15
16
  from dkist_processing_common.tasks import CreateTrialQualityReport
16
17
  from dkist_processing_common.tasks import QualityL1Metrics
@@ -18,6 +19,7 @@ from dkist_processing_common.tasks import WorkflowTaskBase
18
19
  from dkist_service_configuration.logging import logger
19
20
 
20
21
  from dkist_processing_visp.models.constants import VispBudName
22
+ from dkist_processing_visp.models.fits_access import VispMetadataKey
21
23
  from dkist_processing_visp.models.tags import VispTag
22
24
  from dkist_processing_visp.tasks import AssembleVispMovie
23
25
  from dkist_processing_visp.tasks import MakeVispMovieFrames
@@ -156,7 +158,7 @@ class TagPolcalAsScience(VispTaskBase):
156
158
  idx = 0
157
159
  if hdul[idx].data is None:
158
160
  idx = 1
159
- hdul[idx].header["VSPSTP"] = raster_step
161
+ hdul[idx].header[VispMetadataKey.raster_scan_step] = raster_step
160
162
  hdul.flush()
161
163
  del hdul
162
164
 
@@ -201,9 +203,9 @@ def write_L1_files_task(prefix: str = ""):
201
203
 
202
204
  def l1_filename(self, header: fits.Header, stokes: Literal["I", "Q", "U", "V"]):
203
205
  """Do."""
204
- wavelength = str(round(header["LINEWAV"] * 1000)).zfill(8)
206
+ wavelength = str(round(header[MetadataKey.wavelength] * 1000)).zfill(8)
205
207
  cs_step = header["VSPMAP"]
206
- raster_step = header["VSPSTP"]
208
+ raster_step = header[VispMetadataKey.raster_scan_step]
207
209
  return f"{prefix}CS_STEP_{cs_step:02n}_{raster_step:02n}_{wavelength}_{stokes}_L1.fits"
208
210
 
209
211
  return WritePolcalL1Files
@@ -30,6 +30,7 @@ from loguru import logger
30
30
 
31
31
  from dkist_processing_visp.models.constants import VispBudName
32
32
  from dkist_processing_visp.models.constants import VispConstants
33
+ from dkist_processing_visp.models.fits_access import VispMetadataKey
33
34
  from dkist_processing_visp.models.parameters import VispParsingParameters
34
35
  from dkist_processing_visp.models.tags import VispTag
35
36
  from dkist_processing_visp.models.task_name import VispTaskName
@@ -575,9 +576,9 @@ class TagSingleSolarGainAsScience(VispTaskBase):
575
576
  avg_array = average_numpy_arrays(arrays=arrays)
576
577
 
577
578
  hdul = fits.HDUList([fits.PrimaryHDU(data=avg_array, header=first_header)])
578
- hdul[0].header["VSPSTP"] = 0
579
- hdul[0].header["VSPNSTP"] = 1
580
- hdul[0].header["VSPSTNUM"] = 1
579
+ hdul[0].header[VispMetadataKey.raster_scan_step] = 0
580
+ hdul[0].header[VispMetadataKey.total_raster_steps] = 1
581
+ hdul[0].header[VispMetadataKey.modulator_state] = 1
581
582
  hdul[0].header["VSPPOLMD"] = "observe_intensity"
582
583
  # hdul[0].header["POL_NOIS"] = 0.666
583
584
  # hdul[0].header["POL_SENS"] = 0.666
@@ -626,9 +627,9 @@ class TagModulatedSolarGainsAsScience(VispTaskBase):
626
627
  avg_array = average_numpy_arrays(arrays=arrays)
627
628
 
628
629
  hdul = fits.HDUList([fits.PrimaryHDU(data=avg_array, header=first_header)])
629
- hdul[0].header["VSPSTP"] = 0
630
- hdul[0].header["VSPNSTP"] = 1
631
- hdul[0].header["VSPSTNUM"] = modstate
630
+ hdul[0].header[VispMetadataKey.raster_scan_step] = 0
631
+ hdul[0].header[VispMetadataKey.total_raster_steps] = 1
632
+ hdul[0].header[VispMetadataKey.modulator_state] = modstate
632
633
  hdul[0].header["VSPPOLMD"] = "observe_polarimetric"
633
634
 
634
635
  new_tags = [
@@ -0,0 +1,43 @@
1
+ import pytest
2
+ from dkist_header_validator.translator import translate_spec122_to_spec214_l0
3
+
4
+ from dkist_processing_visp.models.fits_access import VispMetadataKey
5
+ from dkist_processing_visp.parsers.visp_l0_fits_access import VispL0FitsAccess
6
+ from dkist_processing_visp.parsers.visp_l1_fits_access import VispL1FitsAccess
7
+ from dkist_processing_visp.tests.header_models import VispHeadersValidObserveFrames
8
+
9
+
10
+ @pytest.fixture(scope="session")
11
+ def complete_header():
12
+ dataset = VispHeadersValidObserveFrames(
13
+ array_shape=(1, 1, 1),
14
+ time_delta=10,
15
+ num_maps=1,
16
+ num_raster_steps=1,
17
+ num_modstates=1,
18
+ )
19
+ header = translate_spec122_to_spec214_l0(dataset.header())
20
+ return header
21
+
22
+
23
+ def test_metadata_keys_in_access_bases(complete_header):
24
+ """
25
+ Given: the set of metadata key names in VispMetadataKey
26
+ When: the ViSP FITS access classes define a set of new attributes
27
+ Then: the sets are the same and the attributes have the correct values
28
+ """
29
+ # Given
30
+ visp_metadata_key_names = {vmk.name for vmk in VispMetadataKey}
31
+ # When
32
+ all_visp_fits_access_attrs = set()
33
+ for access_class in [VispL0FitsAccess, VispL1FitsAccess]:
34
+ fits_obj = access_class.from_header(complete_header)
35
+ visp_instance_attrs = set(vars(fits_obj).keys())
36
+ parent_class = access_class.mro()[1]
37
+ parent_fits_obj = parent_class.from_header(complete_header)
38
+ parent_instance_attrs = set(vars(parent_fits_obj).keys())
39
+ visp_fits_access_attrs = visp_instance_attrs - parent_instance_attrs
40
+ for attr in visp_fits_access_attrs:
41
+ assert getattr(fits_obj, attr) == fits_obj.header[VispMetadataKey[attr]]
42
+ all_visp_fits_access_attrs |= visp_fits_access_attrs
43
+ assert visp_metadata_key_names == all_visp_fits_access_attrs
@@ -1,6 +1,7 @@
1
1
  import pytest
2
2
  from astropy.io import fits
3
3
  from dkist_processing_common._util.scratch import WorkflowFileSystem
4
+ from dkist_processing_common.models.fits_access import MetadataKey
4
5
 
5
6
  from dkist_processing_visp.models.tags import VispTag
6
7
  from dkist_processing_visp.tasks.make_movie_frames import MakeVispMovieFrames
@@ -56,5 +57,5 @@ def test_make_movie_frames(movie_frames_task, pol_mode, mocker, fake_gql_client)
56
57
  for filepath in task.read(tags=[VispTag.movie_frame()]):
57
58
  assert filepath.exists()
58
59
  hdul = fits.open(filepath)
59
- assert hdul[0].header["INSTRUME"] == "VISP"
60
+ assert hdul[0].header[MetadataKey.instrument] == "VISP"
60
61
  assert hdul[0].data.shape == expected_movie_fram_shape
@@ -14,6 +14,7 @@ from dkist_processing_common.parsers.single_value_single_key_flower import (
14
14
  )
15
15
 
16
16
  from dkist_processing_visp.models.constants import VispBudName
17
+ from dkist_processing_visp.models.fits_access import VispMetadataKey
17
18
  from dkist_processing_visp.models.tags import VispStemName
18
19
  from dkist_processing_visp.models.tags import VispTag
19
20
  from dkist_processing_visp.parsers.map_repeats import MapScanFlower
@@ -90,7 +91,8 @@ class ParseTaskJustMapStuff(ParseL0VispInputData):
90
91
  MapScanFlower(),
91
92
  RasterScanStepFlower(),
92
93
  SingleValueSingleKeyFlower(
93
- tag_stem_name=VispStemName.modstate.value, metadata_key="modulator_state"
94
+ tag_stem_name=VispStemName.modstate.value,
95
+ metadata_key=VispMetadataKey.modulator_state,
94
96
  ),
95
97
  ]
96
98
 
@@ -12,6 +12,7 @@ from dkist_header_validator import spec122_validator
12
12
  from dkist_processing_common._util.scratch import WorkflowFileSystem
13
13
  from dkist_processing_common.codecs.fits import fits_array_encoder
14
14
  from dkist_processing_common.codecs.fits import fits_hdu_decoder
15
+ from dkist_processing_common.models.fits_access import MetadataKey
15
16
  from dkist_processing_common.models.tags import Tag
16
17
 
17
18
  from dkist_processing_visp.models.tags import VispStemName
@@ -183,7 +184,7 @@ def headers_with_dates() -> tuple[list[fits.Header], str, int, int]:
183
184
  ]
184
185
  random.shuffle(headers) # Shuffle to make sure they're not already in time order
185
186
  for h in headers:
186
- h["XPOSURE"] = exp_time # Exposure time, in ms
187
+ h[MetadataKey.fpa_exposure_time_ms] = exp_time # Exposure time, in ms
187
188
 
188
189
  return headers, start_time, exp_time, time_delta
189
190
 
@@ -363,10 +364,10 @@ def test_science_calibration_task(
363
364
  assert header["VSPMAP"] == map_scan
364
365
 
365
366
  # Check that WCS keys were updated
366
- if offsets[1, 0, 0] > 0:
367
- assert header["CRPIX2"] == input_header["CRPIX2"] - np.ceil(offsets[1, 0, 0])
368
- if offsets[1, 0, 1] > 0:
369
- assert header["CRPIX1"] == input_header["CRPIX1"] - np.ceil(offsets[1, 0, 1])
367
+ if offsets[1, 0, 0] < 0:
368
+ assert header["CRPIX2"] == input_header["CRPIX2"] - np.ceil(-offsets[1, 0, 0])
369
+ if offsets[1, 0, 1] < 0:
370
+ assert header["CRPIX1"] == input_header["CRPIX1"] - np.ceil(-offsets[1, 0, 1])
370
371
 
371
372
  quality_files = task.read(tags=[Tag.quality("TASK_TYPES")])
372
373
  for file in quality_files:
@@ -419,7 +420,7 @@ def test_readout_normalization_correct(
419
420
  )
420
421
 
421
422
  # When:
422
- corrected_array, _ = task.correct_single_frame(
423
+ corrected_array, _, _ = task.correct_single_frame(
423
424
  beam=1,
424
425
  modstate=1,
425
426
  raster_step=1,
@@ -512,7 +513,7 @@ def test_compute_date_keys_compressed_headers(
512
513
  [[1.0, 2.0], [11.0, 10.0], [3.0, 2.0]], # Beam 2
513
514
  ]
514
515
  ),
515
- [slice(11, None, None), slice(10, None, None)],
516
+ [slice(0, -11, None), slice(0, -10, None)],
516
517
  ),
517
518
  (
518
519
  np.array(
@@ -521,7 +522,7 @@ def test_compute_date_keys_compressed_headers(
521
522
  [[-1.0, -2.0], [-11.0, -10.0], [-3.0, -2.0]], # Beam 2
522
523
  ]
523
524
  ),
524
- [slice(0, -11, None), slice(0, -10, None)],
525
+ [slice(11, None, None), slice(10, None, None)],
525
526
  ),
526
527
  (
527
528
  np.array(
@@ -530,7 +531,7 @@ def test_compute_date_keys_compressed_headers(
530
531
  [[1.0, 2.0], [-11.0, 10.0], [-3.0, -2.0]], # Beam 2
531
532
  ]
532
533
  ),
533
- [slice(10, -11, None), slice(10, -2, None)],
534
+ [slice(11, -10, None), slice(2, -10, None)],
534
535
  ),
535
536
  ],
536
537
  ids=["All positive", "All negative", "Positive and negative"],
@@ -580,3 +581,98 @@ def test_combine_beams(
580
581
  expected = np.ones((10, 10, 4)) * 2.5
581
582
 
582
583
  np.testing.assert_array_equal(data, expected)
584
+
585
+
586
+ @pytest.mark.parametrize(
587
+ "shifts",
588
+ # Shifts have shape (num_beams, num_modstates, 2)
589
+ # So the inner-most lists below (e.g., [5.0, 6.0]) correspond to [x_shift, y_shit]
590
+ [
591
+ np.array(
592
+ [
593
+ [[0.0, 0.0], [10.0, 2.0], [5.0, 6.0]], # Beam 1
594
+ [[1.0, 2.0], [-11.0, 10.0], [-3.0, -2.0]], # Beam 2
595
+ ]
596
+ ),
597
+ ],
598
+ ids=["Positive and negative"],
599
+ )
600
+ def test_combine_and_cut_nan_masks(
601
+ science_calibration_task, calibration_collection_with_geo_shifts, shifts
602
+ ):
603
+ """
604
+ Given: A ScienceCalibration task and NaN masks, along with geometric shifts
605
+ When: Combining the two NaN masks
606
+ Then: The final mask has NaN values in the correct place and is correctly cropped
607
+ """
608
+ nan_1_location = [0, 1]
609
+ nan_2_location = [50, 50]
610
+ nan_3_location = [4, 1]
611
+ nan_4_location = [55, 63]
612
+ nan_mask_shape = (100, 100)
613
+ nan_mask_1 = np.zeros(shape=nan_mask_shape)
614
+ nan_mask_1[nan_1_location[0], nan_1_location[1]] = np.nan
615
+ nan_mask_1[nan_2_location[0], nan_2_location[1]] = np.nan
616
+ nan_mask_2 = np.zeros(shape=nan_mask_shape)
617
+ nan_mask_2[nan_3_location[0], nan_3_location[1]] = np.nan
618
+ nan_mask_2[nan_4_location[0], nan_4_location[1]] = np.nan
619
+ task, _, _, _, _, _ = science_calibration_task
620
+ combined_nan_mask = task.combine_and_cut_nan_masks(
621
+ nan_masks=[nan_mask_1, nan_mask_2], calibrations=calibration_collection_with_geo_shifts
622
+ )
623
+ beam_1_shifts = shifts[0]
624
+ beam_2_shifts = shifts[1]
625
+ beam_1_x_shifts = [i[0] for i in beam_1_shifts]
626
+ beam_2_x_shifts = [i[0] for i in beam_2_shifts]
627
+ beam_1_y_shifts = [i[1] for i in beam_1_shifts]
628
+ beam_2_y_shifts = [i[1] for i in beam_2_shifts]
629
+ x_shifts = beam_1_x_shifts + beam_2_x_shifts
630
+ y_shifts = beam_1_y_shifts + beam_2_y_shifts
631
+ assert combined_nan_mask.shape == (
632
+ nan_mask_shape[0] - (max(x_shifts) - min(x_shifts)),
633
+ nan_mask_shape[1] - (max(y_shifts) - min(y_shifts)),
634
+ )
635
+ # Check that one NaN value from each original mask is present in the combined mask and in the correct place
636
+ assert (
637
+ combined_nan_mask[
638
+ nan_2_location[0] - int(abs(min(x_shifts))), nan_2_location[1] - int(abs(min(y_shifts)))
639
+ ]
640
+ == True
641
+ )
642
+ assert (
643
+ combined_nan_mask[
644
+ nan_4_location[0] - int(abs(min(x_shifts))), nan_4_location[1] - int(abs(min(y_shifts)))
645
+ ]
646
+ == True
647
+ )
648
+ assert np.sum(combined_nan_mask) == 2 # only two NaN values are in the final mask
649
+
650
+
651
+ def test_generate_nan_mask(science_calibration_task, dummy_calibration_collection):
652
+ """
653
+ Given: a calibration collection
654
+ When: calculating the NaN mask to use
655
+ Then: the mask takes up some, but not all, of the frame size
656
+ """
657
+ task, _, _, _, _, _ = science_calibration_task
658
+ calibration_collection, _, _ = dummy_calibration_collection
659
+ beam = 1
660
+ modstate = 1
661
+ solar_gain_array = calibration_collection.solar_gain[VispTag.beam(beam)][
662
+ VispTag.modstate(modstate)
663
+ ]
664
+ angle = calibration_collection.angle[VispTag.beam(beam)]
665
+ spec_shift = calibration_collection.spec_shift[VispTag.beam(beam)]
666
+ state_offset = calibration_collection.state_offset[VispTag.beam(beam)][
667
+ VispTag.modstate(modstate)
668
+ ]
669
+ nan_mask = task.generate_nan_mask(
670
+ solar_corrected_array=np.random.random(size=solar_gain_array.shape),
671
+ state_offset=state_offset,
672
+ angle=angle,
673
+ spec_shift=spec_shift,
674
+ )
675
+ # Some of the mask is marked as NaN but not all
676
+ assert np.sum(nan_mask) < np.size(nan_mask)
677
+ # Ensure that only zeroes and ones are in the mask
678
+ assert set(np.unique(nan_mask)) == {0, 1}
@@ -4,6 +4,7 @@ from astropy.io import fits
4
4
  from astropy.time import Time
5
5
  from dkist_fits_specifications import __version__ as spec_version
6
6
  from dkist_header_validator import spec214_validator
7
+ from dkist_processing_common.models.fits_access import MetadataKey
7
8
  from dkist_processing_common.models.tags import Tag
8
9
  from dkist_processing_common.models.wavelength import WavelengthRange
9
10
  from dkist_spectral_lines import get_closest_spectral_line
@@ -176,7 +177,7 @@ def test_write_l1_frame(
176
177
 
177
178
  if pol_mode == "observe_polarimetric":
178
179
  assert header["CADENCE"] == 100
179
- assert header["XPOSURE"] == 150
180
+ assert header[MetadataKey.fpa_exposure_time_ms] == 150
180
181
  else:
181
182
  assert header["CADENCE"] == 10
182
- assert header["XPOSURE"] == 15
183
+ assert header[MetadataKey.fpa_exposure_time_ms] == 15
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dkist-processing-visp
3
- Version: 3.6.2
3
+ Version: 4.0.0
4
4
  Summary: Science processing code for the ViSP instrument on DKIST
5
5
  Author-email: NSO / AURA <dkistdc@nso.edu>
6
6
  License: BSD-3-Clause
@@ -76,6 +76,7 @@ Requires-Dist: Flask-Login==0.6.3; extra == "frozen"
76
76
  Requires-Dist: Flask-SQLAlchemy==2.5.1; extra == "frozen"
77
77
  Requires-Dist: Flask-Session==0.5.0; extra == "frozen"
78
78
  Requires-Dist: Flask-WTF==1.2.2; extra == "frozen"
79
+ Requires-Dist: ImageIO==2.37.2; extra == "frozen"
79
80
  Requires-Dist: Jinja2==3.1.6; extra == "frozen"
80
81
  Requires-Dist: Mako==1.3.10; extra == "frozen"
81
82
  Requires-Dist: MarkupSafe==3.0.3; extra == "frozen"
@@ -115,19 +116,19 @@ Requires-Dist: asdf==3.5.0; extra == "frozen"
115
116
  Requires-Dist: asdf_standard==1.4.0; extra == "frozen"
116
117
  Requires-Dist: asdf_transform_schemas==0.6.0; extra == "frozen"
117
118
  Requires-Dist: asgiref==3.10.0; extra == "frozen"
118
- Requires-Dist: asteval==1.0.6; extra == "frozen"
119
+ Requires-Dist: asteval==1.0.7; extra == "frozen"
119
120
  Requires-Dist: astropy==7.0.2; extra == "frozen"
120
- Requires-Dist: astropy-iers-data==0.2025.11.3.0.38.37; extra == "frozen"
121
+ Requires-Dist: astropy-iers-data==0.2025.11.10.0.38.31; extra == "frozen"
121
122
  Requires-Dist: asyncpg==0.30.0; extra == "frozen"
122
123
  Requires-Dist: attrs==25.4.0; extra == "frozen"
123
124
  Requires-Dist: babel==2.17.0; extra == "frozen"
124
125
  Requires-Dist: billiard==4.2.2; extra == "frozen"
125
126
  Requires-Dist: blinker==1.9.0; extra == "frozen"
126
- Requires-Dist: boto3==1.40.64; extra == "frozen"
127
- Requires-Dist: botocore==1.40.64; extra == "frozen"
127
+ Requires-Dist: boto3==1.40.71; extra == "frozen"
128
+ Requires-Dist: botocore==1.40.71; extra == "frozen"
128
129
  Requires-Dist: cachelib==0.13.0; extra == "frozen"
129
130
  Requires-Dist: celery==5.3.1; extra == "frozen"
130
- Requires-Dist: certifi==2025.10.5; extra == "frozen"
131
+ Requires-Dist: certifi==2025.11.12; extra == "frozen"
131
132
  Requires-Dist: cffi==2.0.0; extra == "frozen"
132
133
  Requires-Dist: charset-normalizer==3.4.4; extra == "frozen"
133
134
  Requires-Dist: click==8.3.0; extra == "frozen"
@@ -151,7 +152,7 @@ Requires-Dist: dkist-processing-common==11.8.0; extra == "frozen"
151
152
  Requires-Dist: dkist-processing-core==6.0.0; extra == "frozen"
152
153
  Requires-Dist: dkist-processing-math==2.2.1; extra == "frozen"
153
154
  Requires-Dist: dkist-processing-pac==3.1.1; extra == "frozen"
154
- Requires-Dist: dkist-processing-visp==3.6.2; extra == "frozen"
155
+ Requires-Dist: dkist-processing-visp==4.0.0; extra == "frozen"
155
156
  Requires-Dist: dkist-service-configuration==4.1.7; extra == "frozen"
156
157
  Requires-Dist: dkist-spectral-lines==3.0.0; extra == "frozen"
157
158
  Requires-Dist: dkist_fits_specifications==4.17.0; extra == "frozen"
@@ -163,10 +164,9 @@ Requires-Dist: fonttools==4.60.1; extra == "frozen"
163
164
  Requires-Dist: frozenlist==1.8.0; extra == "frozen"
164
165
  Requires-Dist: fsspec==2025.10.0; extra == "frozen"
165
166
  Requires-Dist: globus-sdk==3.65.0; extra == "frozen"
166
- Requires-Dist: google-re2==1.1.20250805; extra == "frozen"
167
- Requires-Dist: googleapis-common-protos==1.71.0; extra == "frozen"
167
+ Requires-Dist: google-re2==1.1.20251105; extra == "frozen"
168
+ Requires-Dist: googleapis-common-protos==1.72.0; extra == "frozen"
168
169
  Requires-Dist: gqlclient==1.2.3; extra == "frozen"
169
- Requires-Dist: greenlet==3.2.4; extra == "frozen"
170
170
  Requires-Dist: grpcio==1.76.0; extra == "frozen"
171
171
  Requires-Dist: gunicorn==23.0.0; extra == "frozen"
172
172
  Requires-Dist: h11==0.16.0; extra == "frozen"
@@ -174,7 +174,6 @@ Requires-Dist: httpcore==1.0.9; extra == "frozen"
174
174
  Requires-Dist: httpx==0.28.1; extra == "frozen"
175
175
  Requires-Dist: humanize==4.14.0; extra == "frozen"
176
176
  Requires-Dist: idna==3.11; extra == "frozen"
177
- Requires-Dist: imageio==2.37.0; extra == "frozen"
178
177
  Requires-Dist: imageio-ffmpeg==0.6.0; extra == "frozen"
179
178
  Requires-Dist: importlib_metadata==8.7.0; extra == "frozen"
180
179
  Requires-Dist: inflection==0.5.1; extra == "frozen"
@@ -254,9 +253,9 @@ Requires-Dist: protobuf==6.33.0; extra == "frozen"
254
253
  Requires-Dist: psutil==7.1.3; extra == "frozen"
255
254
  Requires-Dist: psycopg2-binary==2.9.11; extra == "frozen"
256
255
  Requires-Dist: pycparser==2.23; extra == "frozen"
257
- Requires-Dist: pydantic==2.12.3; extra == "frozen"
258
- Requires-Dist: pydantic-settings==2.11.0; extra == "frozen"
259
- Requires-Dist: pydantic_core==2.41.4; extra == "frozen"
256
+ Requires-Dist: pydantic==2.12.4; extra == "frozen"
257
+ Requires-Dist: pydantic-settings==2.12.0; extra == "frozen"
258
+ Requires-Dist: pydantic_core==2.41.5; extra == "frozen"
260
259
  Requires-Dist: pyerfa==2.0.1.5; extra == "frozen"
261
260
  Requires-Dist: pyparsing==3.2.5; extra == "frozen"
262
261
  Requires-Dist: python-daemon==3.1.2; extra == "frozen"
@@ -300,7 +299,7 @@ Requires-Dist: typing_extensions==4.15.0; extra == "frozen"
300
299
  Requires-Dist: tzdata==2025.2; extra == "frozen"
301
300
  Requires-Dist: uc-micro-py==1.0.3; extra == "frozen"
302
301
  Requires-Dist: uncertainties==3.2.3; extra == "frozen"
303
- Requires-Dist: universal_pathlib==0.3.4; extra == "frozen"
302
+ Requires-Dist: universal_pathlib==0.3.5; extra == "frozen"
304
303
  Requires-Dist: urllib3==2.5.0; extra == "frozen"
305
304
  Requires-Dist: vine==5.1.0; extra == "frozen"
306
305
  Requires-Dist: voluptuous==0.15.2; extra == "frozen"
@@ -3,17 +3,18 @@ dkist_processing_visp/__init__.py,sha256=LC8o31oTIro4F7IgwoWalX1W3KcPU27yJhlDUeG
3
3
  dkist_processing_visp/config.py,sha256=GMr0CreW4qavbueTtsH_Gx5P52v4yZd2PNKyPmxBKQE,478
4
4
  dkist_processing_visp/fonts/Lato-Regular.ttf,sha256=1jbkaDIx-THtoiLViOlE0IK_0726AvkovuRhwPGFslE,656568
5
5
  dkist_processing_visp/models/__init__.py,sha256=z2nFVvvIzirxklQ9i5-F1nR-WOgcDttYtog_jx4yN5I,12
6
- dkist_processing_visp/models/constants.py,sha256=GLltKc11rVAcLu6SLgMTGrxnyV88YVEg-dO8N8Cx7_0,5201
6
+ dkist_processing_visp/models/constants.py,sha256=sizf2PB3H8BVughHsJhh_fJUeNkN-0uHpo-DVy1_wdo,5140
7
+ dkist_processing_visp/models/fits_access.py,sha256=-W5hP5U9n4uMCg2A_CSJuM3L6dipktZIjoUxyQfT0O8,435
7
8
  dkist_processing_visp/models/parameters.py,sha256=m5o41KsPO99WUgWlY4-ezu4wqvQVMGpQ3fNuMcFkSu0,9831
8
9
  dkist_processing_visp/models/tags.py,sha256=RKtDlNA9O3LMinUP7BJ3tXWn6CWAARaKhXIVB1EHtg0,3055
9
10
  dkist_processing_visp/models/task_name.py,sha256=ykLXcmCBWYguopdBlg_G0X_u-0o_Ugh2117XrCqdbFk,187
10
11
  dkist_processing_visp/parsers/__init__.py,sha256=z2nFVvvIzirxklQ9i5-F1nR-WOgcDttYtog_jx4yN5I,12
11
12
  dkist_processing_visp/parsers/map_repeats.py,sha256=YuO1VROQLuE-Hn9hSzityjNhIDe-EgQ4kjZV6l9xF2Q,5468
12
13
  dkist_processing_visp/parsers/modulator_states.py,sha256=dHAZZaG3i_UUT5FjTg1oJdCBiOKCqkrx1jiQnzp2t2o,3006
13
- dkist_processing_visp/parsers/polarimeter_mode.py,sha256=mrlxFRcJfDojaE7tuB4DTka4fTQbPA6ae2rspE7s5WI,542
14
- dkist_processing_visp/parsers/raster_step.py,sha256=39kpy3PqcRgZluViJLuJnW0eD6LLF6R5QrxWkg50SQk,3046
15
- dkist_processing_visp/parsers/time.py,sha256=kN4B2gJ0XpO2oOOk0CQyuWd-BRUOLLM6P7KQJClvNQk,4715
16
- dkist_processing_visp/parsers/visp_l0_fits_access.py,sha256=JoQezX0yhgyA-UVET7SXILXnny_M6ZuSRz4SSPn9syk,1336
14
+ dkist_processing_visp/parsers/polarimeter_mode.py,sha256=vOE8IYlBrgAFGSrDbpUDfHnI3OTPBDpd3U_j5totZb8,625
15
+ dkist_processing_visp/parsers/raster_step.py,sha256=tCmXPhG99z54YVX3X-kLOGDKJRTDJHnDbjNev4An7Pg,3142
16
+ dkist_processing_visp/parsers/time.py,sha256=uudQ5manYdL7SgxqABfFPDzW2iTNrTYF0klqCRsP0CI,4812
17
+ dkist_processing_visp/parsers/visp_l0_fits_access.py,sha256=vZGLGQNofRFziTSBOTf4_digLlOdRRGeFEvrAQouXTI,1609
17
18
  dkist_processing_visp/parsers/visp_l1_fits_access.py,sha256=1MrFfsJjT_7fd1cj8tFr5rHX2JdRSrlwiMCzu-Q8ejY,860
18
19
  dkist_processing_visp/tasks/__init__.py,sha256=qlPlahiM9_sCsaIj_wzQpzWkMITJ1dPdT93iV9q-fgg,713
19
20
  dkist_processing_visp/tasks/assemble_movie.py,sha256=8UujniXlV_sSGeuISud8wMHihLy6Gc5fKZpwkXLUQB8,3330
@@ -23,16 +24,16 @@ dkist_processing_visp/tasks/geometric.py,sha256=rvWa13T2_cPQtiMjlWrqMYS5UvL8Xgmi
23
24
  dkist_processing_visp/tasks/instrument_polarization.py,sha256=uj7iyzM3CiJcbQeF4eKpk_KCoheXaM4FpDI83GYDld4,25854
24
25
  dkist_processing_visp/tasks/l1_output_data.py,sha256=lon6bIUBvURV_7gn1HFGAGVuEiLoOiMaJwfbdhSRW3w,459
25
26
  dkist_processing_visp/tasks/lamp.py,sha256=RciNB8zW5fDx5yrsaNZgFP7LsajmSvkTNvbe3Sm6tjc,6060
26
- dkist_processing_visp/tasks/make_movie_frames.py,sha256=AMhHTMlcrCX_dI7lHduGZPWitwtfXI9m9FbJKZ8vz1Y,7420
27
- dkist_processing_visp/tasks/parse.py,sha256=tU8Ann3k3cByOGsLe5dIQKZC_RQA50Z2mUu52H3qVCs,6500
28
- dkist_processing_visp/tasks/quality_metrics.py,sha256=Xz0ft1Y6ocJiVIDKe2fp8YxWsy3ctuGvYI-4hWFL-P8,8130
29
- dkist_processing_visp/tasks/science.py,sha256=-tI1bnIEu00nVBaZAhii7cUlFXaFDxZK3wTSWHRhWxA,30815
27
+ dkist_processing_visp/tasks/make_movie_frames.py,sha256=fw25ksKiJJNS57XV5a7rHpYGcSkYxS2Qf13Fb1UGNpE,7544
28
+ dkist_processing_visp/tasks/parse.py,sha256=3f8LWSKQtuY7n-PdHiw1j55i5l4juBn3pIHqTtp2-rk,6752
29
+ dkist_processing_visp/tasks/quality_metrics.py,sha256=Pw55-PXW0cl39FuNkEQCGGhvI_zMDimwmh-swVPVBD4,8133
30
+ dkist_processing_visp/tasks/science.py,sha256=BiEtgDowExHAXXzfO4BSr34BT0PZ28CdtOJMP4TDA20,34680
30
31
  dkist_processing_visp/tasks/solar.py,sha256=QPkrJqUz6gYxFFvNlATMKa4NIEdn4iassYn86qPb60k,27567
31
32
  dkist_processing_visp/tasks/visp_base.py,sha256=flUM-dCWkjcGvfaiFWH89DRZXz7NADgShxzpFjFqVEw,1370
32
- dkist_processing_visp/tasks/write_l1.py,sha256=OuTgzZG_47LZrQ42cYOP6wUUx18TL0o5WxrRwVJrTNM,8541
33
+ dkist_processing_visp/tasks/write_l1.py,sha256=bsDZ0BwoqpTtS_f_rAzUn7Ra8UvYb-kINQhX6BwwFQw,8796
33
34
  dkist_processing_visp/tasks/mixin/__init__.py,sha256=z2nFVvvIzirxklQ9i5-F1nR-WOgcDttYtog_jx4yN5I,12
34
35
  dkist_processing_visp/tasks/mixin/beam_access.py,sha256=1VSJkH6yMxCiZWdWOp_RJ37fX5ULMYmB_0_ulT7YJpI,870
35
- dkist_processing_visp/tasks/mixin/corrections.py,sha256=tsXexnqsYgd14CjGYrsBxGjX7ZTUVrTj6_-2VdKeWP0,5666
36
+ dkist_processing_visp/tasks/mixin/corrections.py,sha256=FhLFgD9ZYLZd3SaC3PFF-szrcs-zmdrUYNDUEK-h7JA,7145
36
37
  dkist_processing_visp/tasks/mixin/downsample.py,sha256=SvKzY6HJRn-FeyG7O6HPvyOS5dmMu6uPoWkfnpPXpVw,1344
37
38
  dkist_processing_visp/tasks/mixin/line_zones.py,sha256=5jfea9V5RJAi-834z_Y9v4fhlRFJdK1McAqO9X92bZo,4065
38
39
  dkist_processing_visp/tests/README.rst,sha256=rnedwwg25c0lB9Me7cT7QNZA17FYlqCu9ZnjQxR5hi0,12502
@@ -44,26 +45,27 @@ dkist_processing_visp/tests/test_assemble_quality.py,sha256=Hm0nAW90Kbb-6OLkUsW6
44
45
  dkist_processing_visp/tests/test_background_light.py,sha256=Zvm8s38qx_ybviEhnKqPI4s36VFBJKtsNrp31-o8lEQ,17553
45
46
  dkist_processing_visp/tests/test_dark.py,sha256=iKp12bHOOKPf7GAB8iKYpZu1AXFaESAW6C0ua1nVFXA,5552
46
47
  dkist_processing_visp/tests/test_downsample.py,sha256=iSmb4PwpZtnVU06tmlko1wwepWueQ3KJ459XYgNIpws,2211
48
+ dkist_processing_visp/tests/test_fits_access.py,sha256=jOaSHDydzPl48SSBxt3z3CtCzBgCmSCc3rZ92CpoAaY,1822
47
49
  dkist_processing_visp/tests/test_geometric.py,sha256=60W1Vv16JtQBnsP5CS10sOksXF8NzfoQDvl46C7fT3U,12536
48
50
  dkist_processing_visp/tests/test_instrument_polarization.py,sha256=AntdpdmKtUJmr0VtooucqaMzXwgPqFgChU88_vFUtBU,11853
49
51
  dkist_processing_visp/tests/test_lamp.py,sha256=_ibg4ORd_19eORS24zgJZfkOxIdijr1eWKVGHWk-PKY,5212
50
- dkist_processing_visp/tests/test_make_movie_frames.py,sha256=lZlUZNYmvfNSH8DKOtMByoi0vDYOmGKV3LG1rD3mWOw,2549
51
- dkist_processing_visp/tests/test_map_repeats.py,sha256=i_niVHsKAFrqk_weisKcU7JAkcfI3muOWFDHQiPyB9A,7511
52
+ dkist_processing_visp/tests/test_make_movie_frames.py,sha256=huQ5n0YneHByKumM_Ye9tekqKeh-F-e6MQoudOP3S-g,2628
53
+ dkist_processing_visp/tests/test_map_repeats.py,sha256=9g3NnvSfn1OqxxYYxTFoOIi1UsCOa6mZjiuGkbxUvTg,7611
52
54
  dkist_processing_visp/tests/test_parameters.py,sha256=h9EemGJf0b4ma0jLGd321untkhLkhwgo88mIRnSmxXs,4369
53
55
  dkist_processing_visp/tests/test_parse.py,sha256=aFBbLBXdz2KJZy62gC1KC049FFfRKYqYVo-Y32mx_E4,20853
54
56
  dkist_processing_visp/tests/test_quality.py,sha256=YW24VjEHoILseFIXZBp4-o7egT26mfT1lafzajVjXu8,6905
55
- dkist_processing_visp/tests/test_science.py,sha256=eoBH83Mk9pXHdfe_kYWgueLysUkujj2P5nqfa5MibAQ,20182
57
+ dkist_processing_visp/tests/test_science.py,sha256=rIoHu1hifNYj1niQaDkmyID5BQVzTEgZ5BhsdZOSaWk,23985
56
58
  dkist_processing_visp/tests/test_solar.py,sha256=Bpkm59HI0GqrwM8LqIK7GR0pUUW0anboJkePQnofgmA,9471
57
59
  dkist_processing_visp/tests/test_trial_create_quality_report.py,sha256=t3de9LMJOqrRaFXAvKV_5sotAfzDR8fZVIrFNRB2I_A,2779
58
60
  dkist_processing_visp/tests/test_visp_constants.py,sha256=v8meAXdR6jJntaFtWR-N65Z-3ovr9Vf9_-BwAZv2RBc,1974
59
61
  dkist_processing_visp/tests/test_workflows.py,sha256=qyWxagIDv-MmVN0u3KFswa5HdaHC6uGeJpvgxvPE30E,287
60
- dkist_processing_visp/tests/test_write_l1.py,sha256=_RtTE9JRNLKXEw9Nr98BmLInWn3zIh2o6SBj_qv6xh4,6461
62
+ dkist_processing_visp/tests/test_write_l1.py,sha256=ORNKUvF4RPjXc24HgOzyVsP0BCQ9izi54EqXpjLzHXQ,6574
61
63
  dkist_processing_visp/tests/local_trial_workflows/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
62
64
  dkist_processing_visp/tests/local_trial_workflows/l0_cals_only.py,sha256=1TmvdAn80WKW-2DsuSg9iK6yQ3Urwm9yt5HlVv1dGWE,10649
63
- dkist_processing_visp/tests/local_trial_workflows/l0_polcals_as_science.py,sha256=CiEBouMAc9lj4sqwjmclS7ryFpr_BkMi-wC5S0F7mmI,17370
65
+ dkist_processing_visp/tests/local_trial_workflows/l0_polcals_as_science.py,sha256=geU6Q8ULfIq4Jmj6ORq8mgXYlF8SYrvgZP-0u-4Uz24,17567
64
66
  dkist_processing_visp/tests/local_trial_workflows/l0_solar_gain_as_science.py,sha256=cRrLklazesLBHPPbGzVjBXbEQKaCxQfgy1_AgWyZRyo,15820
65
67
  dkist_processing_visp/tests/local_trial_workflows/l0_to_l1.py,sha256=cwEdvUGqqSpp4htrxlmyRN2sZBBvVOjomd66DEqTmTA,15191
66
- dkist_processing_visp/tests/local_trial_workflows/local_trial_helpers.py,sha256=q7sGn3GlwCB60EBk3Noujfj6n6GuocxxX25348iwfZw,26541
68
+ dkist_processing_visp/tests/local_trial_workflows/local_trial_helpers.py,sha256=eSOuCn7c2DYATq3twu9Gqj7DrLR902p7b5vGlHZ3zxU,26750
67
69
  dkist_processing_visp/workflows/__init__.py,sha256=1-GP9tOzjCxLJtyq0ry_x4dPdArfSso8Hxu65ydPpXQ,103
68
70
  dkist_processing_visp/workflows/l0_processing.py,sha256=VzTjyS-ywhwATN1Hc17B7tdZOwu3Cv3dUv9l5Ux_vqY,3262
69
71
  dkist_processing_visp/workflows/single_task_workflows.py,sha256=LK4dsshM0-lwy79WaMoTplyCxUyINnP9RU74MG_dhyc,33
@@ -85,7 +87,7 @@ docs/requirements_table.rst,sha256=_HIbwFpDooM5n0JjiDAbFozGfJuX13smtcoujLFN4Gk,2
85
87
  docs/science_calibration.rst,sha256=VN_g7xSjN-nbXhlBaFnPCbNcsc_Qu0207jEUfRAjnBE,2939
86
88
  docs/scientific_changelog.rst,sha256=01AWBSHg8zElnodCgAq-hMxhk9CkX5rtEENx4iz0sjI,300
87
89
  licenses/LICENSE.rst,sha256=piZaQplkzOMmH1NXg6QIdo9wwo9pPCoHkvm2-DmH76E,1462
88
- dkist_processing_visp-3.6.2.dist-info/METADATA,sha256=cmUCBaNYTqGxhJ1OcmAT1kB8enj-4SlnlpEQwg8Sj7A,29121
89
- dkist_processing_visp-3.6.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
90
- dkist_processing_visp-3.6.2.dist-info/top_level.txt,sha256=9GHSn-ZMGQxaRNGrPP3HNc5ZkE7ftzluO74Jz5vUSTE,46
91
- dkist_processing_visp-3.6.2.dist-info/RECORD,,
90
+ dkist_processing_visp-4.0.0.dist-info/METADATA,sha256=4S8qtlI-Cp12PpvTDgD0ZgSA2ZaaOp0QnHtC94RZ4Ak,29073
91
+ dkist_processing_visp-4.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
92
+ dkist_processing_visp-4.0.0.dist-info/top_level.txt,sha256=9GHSn-ZMGQxaRNGrPP3HNc5ZkE7ftzluO74Jz5vUSTE,46
93
+ dkist_processing_visp-4.0.0.dist-info/RECORD,,