dkist-processing-visp 5.1.2rc1__py3-none-any.whl → 5.2.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.
Files changed (30) hide show
  1. dkist_processing_visp/models/parameters.py +43 -1
  2. dkist_processing_visp/models/tags.py +11 -0
  3. dkist_processing_visp/models/task_name.py +2 -0
  4. dkist_processing_visp/tasks/geometric.py +1 -1
  5. dkist_processing_visp/tasks/science.py +88 -11
  6. dkist_processing_visp/tasks/solar.py +12 -205
  7. dkist_processing_visp/tasks/wavelength_calibration.py +430 -0
  8. dkist_processing_visp/tasks/write_l1.py +2 -0
  9. dkist_processing_visp/tests/conftest.py +11 -0
  10. dkist_processing_visp/tests/header_models.py +22 -6
  11. dkist_processing_visp/tests/local_trial_workflows/l0_cals_only.py +21 -0
  12. dkist_processing_visp/tests/local_trial_workflows/l0_polcals_as_science.py +21 -0
  13. dkist_processing_visp/tests/local_trial_workflows/l0_solar_gain_as_science.py +20 -0
  14. dkist_processing_visp/tests/local_trial_workflows/l0_to_l1.py +21 -0
  15. dkist_processing_visp/tests/local_trial_workflows/local_trial_helpers.py +27 -0
  16. dkist_processing_visp/tests/test_parameters.py +11 -5
  17. dkist_processing_visp/tests/test_science.py +60 -5
  18. dkist_processing_visp/tests/test_solar.py +0 -1
  19. dkist_processing_visp/tests/test_wavelength_calibration.py +297 -0
  20. dkist_processing_visp/tests/test_write_l1.py +0 -2
  21. dkist_processing_visp/workflows/l0_processing.py +4 -1
  22. dkist_processing_visp/workflows/trial_workflows.py +7 -2
  23. {dkist_processing_visp-5.1.2rc1.dist-info → dkist_processing_visp-5.2.0.dist-info}/METADATA +37 -37
  24. {dkist_processing_visp-5.1.2rc1.dist-info → dkist_processing_visp-5.2.0.dist-info}/RECORD +29 -27
  25. docs/gain_correction.rst +3 -0
  26. docs/index.rst +1 -0
  27. docs/wavelength_calibration.rst +64 -0
  28. changelog/251.feature.rst +0 -1
  29. {dkist_processing_visp-5.1.2rc1.dist-info → dkist_processing_visp-5.2.0.dist-info}/WHEEL +0 -0
  30. {dkist_processing_visp-5.1.2rc1.dist-info → dkist_processing_visp-5.2.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,430 @@
1
+ """Visp wavelength calibration task."""
2
+
3
+ import astropy.units as u
4
+ import numpy as np
5
+ from astropy.time import Time
6
+ from astropy.units import Quantity
7
+ from astropy.wcs import WCS
8
+ from dkist_processing_common.codecs.fits import fits_array_decoder
9
+ from dkist_processing_common.codecs.json import json_encoder
10
+ from dkist_processing_common.models.dkist_location import location_of_dkist
11
+ from dkist_processing_common.tasks.mixin.quality import QualityMixin
12
+ from dkist_service_configuration.logging import logger
13
+ from solar_wavelength_calibration import Atlas
14
+ from solar_wavelength_calibration import WavelengthCalibrationFitter
15
+ from solar_wavelength_calibration.fitter.parameters import AngleBoundRange
16
+ from solar_wavelength_calibration.fitter.parameters import BoundsModel
17
+ from solar_wavelength_calibration.fitter.parameters import DispersionBoundRange
18
+ from solar_wavelength_calibration.fitter.parameters import FitFlagsModel
19
+ from solar_wavelength_calibration.fitter.parameters import LengthBoundRange
20
+ from solar_wavelength_calibration.fitter.parameters import UnitlessBoundRange
21
+ from solar_wavelength_calibration.fitter.parameters import WavelengthCalibrationParameters
22
+ from solar_wavelength_calibration.fitter.wavelength_fitter import WavelengthParameters
23
+ from solar_wavelength_calibration.fitter.wavelength_fitter import calculate_initial_crval_guess
24
+ from sunpy.coordinates import HeliocentricInertial
25
+
26
+ from dkist_processing_visp.models.tags import VispTag
27
+ from dkist_processing_visp.tasks.visp_base import VispTaskBase
28
+
29
+ __all__ = ["WavelengthCalibration"]
30
+
31
+
32
+ class WavelengthCalibration(VispTaskBase, QualityMixin):
33
+ """Task class for correcting the dispersion axis wavelength values.
34
+
35
+ Parameters
36
+ ----------
37
+ recipe_run_id : int
38
+ id of the recipe run used to identify the workflow run this task is part of
39
+ workflow_name : str
40
+ name of the workflow to which this instance of the task belongs
41
+ workflow_version : str
42
+ version of the workflow to which this instance of the task belongs
43
+
44
+ """
45
+
46
+ record_provenance = True
47
+
48
+ def run(self) -> None:
49
+ """
50
+ Run method for the task.
51
+
52
+ #. Gather 2D characteristic spectra for beam 1 generated by solar gain task.
53
+ #. Compute spatial median of characteristic spectra as initial spectrum.
54
+ #. Compute the theoretical dispersion and order.
55
+ #. Compute the incident light angle.
56
+ #. Generate the input wavelength vector from the spectrum and instrument parameters.
57
+ #. Estimate the Doppler velocity and set the resolving power.
58
+ #. Define fitting bounds and initialize model parameters.
59
+ #. Set up and run the wavelength calibration fit.
60
+ #. Save the resulting wavelength solution and quality metrics.
61
+
62
+ Returns
63
+ -------
64
+ None
65
+ """
66
+ with self.telemetry_span("Compute input spectrum and wavelength"):
67
+ logger.info("Loading characteristic spectrum")
68
+ char_spectra = next(
69
+ self.read(
70
+ tags=[
71
+ VispTag.intermediate_frame(beam=1),
72
+ VispTag.task_characteristic_spectra(),
73
+ ],
74
+ decoder=fits_array_decoder,
75
+ )
76
+ )
77
+
78
+ logger.info("Calculating input spectrum")
79
+ # average along spatial dimension
80
+ med_char_spectrum = np.nanmedian(char_spectra, axis=1)
81
+ input_spectrum, nan_chop_amount = self.chop_and_clean_NaNs(med_char_spectrum)
82
+
83
+ incident_light_angle = self.constants.incident_light_angle_deg
84
+ logger.info(f"{incident_light_angle = !s}")
85
+
86
+ wavelength = self.constants.wavelength * u.nm
87
+ reflected_light_angle = self.constants.reflected_light_angle_deg
88
+ order = compute_order(
89
+ central_wavelength=wavelength,
90
+ incident_light_angle=incident_light_angle,
91
+ reflected_light_angle=reflected_light_angle,
92
+ grating_constant=self.constants.grating_constant_inverse_mm,
93
+ )
94
+ logger.info(f"{order = }")
95
+
96
+ pixpitch = self.parameters.wavecal_pixel_pitch_micron_per_pix
97
+ dispersion = compute_initial_dispersion(
98
+ central_wavelength=wavelength,
99
+ incident_light_angle=incident_light_angle,
100
+ reflected_light_angle=reflected_light_angle,
101
+ lens_parameters=self.parameters.wavecal_camera_lens_parameters,
102
+ pixel_pitch=pixpitch,
103
+ )
104
+ logger.info(f"{dispersion = !s}")
105
+
106
+ with self.telemetry_span("Compute brute-force CRVAL initial guess"):
107
+ logger.info("Computing initial wavelength vector.")
108
+ input_wavelength_vector = compute_input_wavelength_vector(
109
+ central_wavelength=wavelength,
110
+ dispersion=dispersion,
111
+ grating_constant=self.constants.grating_constant_inverse_mm,
112
+ order=order,
113
+ incident_light_angle=incident_light_angle,
114
+ num_spec_px=input_spectrum.size,
115
+ )
116
+
117
+ wavelength_range = input_wavelength_vector[-1] - input_wavelength_vector[0]
118
+ logger.info(f"{wavelength_range = !s}")
119
+
120
+ atlas = Atlas(config=self.parameters.wavecal_atlas_download_config)
121
+ crval_initial_guess = calculate_initial_crval_guess(
122
+ input_wavelength_vector=input_wavelength_vector,
123
+ input_spectrum=input_spectrum,
124
+ atlas=atlas,
125
+ negative_limit=-wavelength_range / 2,
126
+ positive_limit=wavelength_range / 2,
127
+ num_steps=550,
128
+ )
129
+ logger.info(f"{crval_initial_guess = !s}")
130
+
131
+ with self.telemetry_span("Set up wavelength fit"):
132
+ doppler_velocity = get_doppler_velocity(self.constants.solar_gain_ip_start_time)
133
+ logger.info(f"{doppler_velocity = !s}")
134
+
135
+ resolving_power = self.parameters.wavecal_init_resolving_power
136
+ logger.info(f"{resolving_power = }")
137
+
138
+ logger.info("Setting bounds")
139
+ wavelength_search_width = dispersion * self.parameters.wavecal_crval_bounds_px
140
+ bounds = BoundsModel(
141
+ crval=LengthBoundRange(
142
+ min=crval_initial_guess - wavelength_search_width,
143
+ max=crval_initial_guess + wavelength_search_width,
144
+ ),
145
+ dispersion=DispersionBoundRange(
146
+ min=dispersion * (1 - self.parameters.wavecal_dispersion_bounds_fraction),
147
+ max=dispersion * (1 + self.parameters.wavecal_dispersion_bounds_fraction),
148
+ ),
149
+ incident_light_angle=AngleBoundRange(
150
+ min=incident_light_angle
151
+ - self.parameters.wavecal_incident_light_angle_bounds_deg,
152
+ max=incident_light_angle
153
+ + self.parameters.wavecal_incident_light_angle_bounds_deg,
154
+ ),
155
+ resolving_power=UnitlessBoundRange(
156
+ min=resolving_power - (resolving_power * 0.1),
157
+ max=resolving_power + (resolving_power * 0.1),
158
+ ),
159
+ opacity_factor=UnitlessBoundRange(min=0.0, max=10.0),
160
+ straylight_fraction=UnitlessBoundRange(min=0.0, max=0.4),
161
+ continuum_level=UnitlessBoundRange(min=0.5, max=2.0),
162
+ )
163
+
164
+ fit_flags = FitFlagsModel(
165
+ crval=True,
166
+ dispersion=True,
167
+ incident_light_angle=True,
168
+ resolving_power=True,
169
+ opacity_factor=True,
170
+ straylight_fraction=True,
171
+ continuum_level=True,
172
+ )
173
+
174
+ logger.info("Initializing parameters")
175
+ input_parameters = WavelengthCalibrationParameters(
176
+ crval=crval_initial_guess,
177
+ dispersion=dispersion,
178
+ incident_light_angle=incident_light_angle,
179
+ resolving_power=resolving_power,
180
+ opacity_factor=self.parameters.wavecal_init_opacity_factor,
181
+ straylight_fraction=self.parameters.wavecal_init_straylight_fraction,
182
+ grating_constant=self.constants.grating_constant_inverse_mm,
183
+ doppler_velocity=doppler_velocity,
184
+ order=order,
185
+ bounds=bounds,
186
+ fit_flags=fit_flags,
187
+ )
188
+
189
+ fitter = WavelengthCalibrationFitter(
190
+ input_parameters=input_parameters,
191
+ )
192
+
193
+ logger.info(f"Input parameters: {input_parameters.lmfit_parameters.pretty_repr()}")
194
+
195
+ with self.telemetry_span("Run wavelength solution fit"):
196
+ extra_kwargs = self.parameters.wavecal_fit_kwargs
197
+ logger.info(f"Calling fitter with extra kwargs: {extra_kwargs}")
198
+ fit_result = fitter(
199
+ input_spectrum=input_spectrum,
200
+ **extra_kwargs,
201
+ )
202
+
203
+ with self.telemetry_span("Save wavelength solution and quality metrics"):
204
+ axis_number = next(
205
+ (i for i in range(1, 4) if getattr(self.constants, f"axis_{i}_type") == "AWAV"),
206
+ None,
207
+ )
208
+
209
+ if axis_number is None:
210
+ raise ValueError("No axis equal to AWAV. This should never happen!")
211
+
212
+ solution_header = fit_result.wavelength_parameters.to_header(
213
+ axis_num=axis_number, add_alternate_keys=True
214
+ )
215
+
216
+ # update the fit value of CRPIX to account for the fact that we may have chopped some NaN pixels away from the start of the data array
217
+ solution_header[f"CRPIX{axis_number}"] += nan_chop_amount
218
+ solution_header[f"CRPIX{axis_number}A"] += nan_chop_amount
219
+
220
+ self.write(
221
+ data=solution_header,
222
+ tags=[VispTag.task_wavelength_calibration(), VispTag.intermediate()],
223
+ encoder=json_encoder,
224
+ )
225
+
226
+ self.quality_store_wavecal_results(
227
+ input_wavelength=input_wavelength_vector,
228
+ input_spectrum=input_spectrum,
229
+ fit_result=fit_result,
230
+ )
231
+
232
+ def chop_and_clean_NaNs(self, spectrum: np.ndarray) -> tuple[np.ndarray, int]:
233
+ """
234
+ Chop contiguous regions of NaN from either end of an array.
235
+
236
+ Returns
237
+ -------
238
+ np.ndarray
239
+ The input array with NaN's removed
240
+
241
+ int
242
+ The number of pixels chopped from the start of the array. This is needed to correctly adjust CRPIX later.
243
+ """
244
+ starting_non_nan_idx = 0
245
+ while np.isnan(spectrum[starting_non_nan_idx]):
246
+ starting_non_nan_idx += 1
247
+
248
+ ending_non_nan_idx = spectrum.size - 1
249
+ while np.isnan(spectrum[ending_non_nan_idx]):
250
+ ending_non_nan_idx -= 1
251
+
252
+ logger.info(
253
+ f"Chopping NaN values from end of spectrum with slice [{starting_non_nan_idx}:{ending_non_nan_idx + 1}]"
254
+ )
255
+ chopped_spectrum = spectrum[starting_non_nan_idx : ending_non_nan_idx + 1]
256
+
257
+ return chopped_spectrum, starting_non_nan_idx
258
+
259
+
260
+ def get_doppler_velocity(solar_gain_ip_start_time) -> u.Quantity:
261
+ """Find the speed at which DKIST is moving relative to the Sun's center.
262
+
263
+ Positive values refer to when DKIST is moving away from the sun.
264
+ """
265
+ coord = location_of_dkist.get_gcrs(obstime=Time(solar_gain_ip_start_time))
266
+ heliocentric_coord = coord.transform_to(
267
+ HeliocentricInertial(obstime=Time(solar_gain_ip_start_time))
268
+ )
269
+ obs_vr_kms = heliocentric_coord.d_distance
270
+ return obs_vr_kms
271
+
272
+
273
+ def compute_order(
274
+ central_wavelength: Quantity,
275
+ incident_light_angle: Quantity,
276
+ reflected_light_angle: Quantity,
277
+ grating_constant: Quantity,
278
+ ) -> int:
279
+ r"""
280
+ Compute the spectral order from the spectrograph setup.
281
+
282
+ From the grating equation, the spectral order, :math:`m`:, is
283
+
284
+ .. math::
285
+ m = \frac{\sin\alpha + \sin\beta}{G \lambda}
286
+
287
+ where :math:`\alpha` and :math:`\beta` are the incident and reflected light angles, respectively, :math:`G` is the
288
+ grating constant (lines per mm), and :math:`\lambda` is the central wavelength. All of these values come from the
289
+ input headers.
290
+
291
+ Parameters
292
+ ----------
293
+ central_wavelength
294
+ Wavelength of the center of the spectral window.
295
+
296
+ incident_light_angle
297
+ Angle of light incident to the spectrograph grating. Often called :math:`\alpha`.
298
+
299
+ reflected_light_angle
300
+ Angle of light reflected from spectrograph grating. Often called :math:`\beta`.
301
+
302
+ grating_constant
303
+ Grating constant of the spectrograph grating [lines per mm]
304
+
305
+ Returns
306
+ -------
307
+ spectral_order
308
+ The order of the given spectrograph configuration
309
+ """
310
+ return int(
311
+ (np.sin(incident_light_angle) + np.sin(reflected_light_angle))
312
+ / (grating_constant * central_wavelength)
313
+ )
314
+
315
+
316
+ def compute_input_wavelength_vector(
317
+ *,
318
+ central_wavelength: Quantity,
319
+ dispersion: Quantity,
320
+ grating_constant: Quantity,
321
+ order: int,
322
+ incident_light_angle: Quantity,
323
+ num_spec_px: int,
324
+ ) -> u.Quantity:
325
+ r"""
326
+ Compute a wavelength vector based on information about the spectrograph setup.
327
+
328
+ The parameterization of the grating equation is via `astropy.wcs.WCS`, which follows section 5 of
329
+ `Greisen et al (2006) <https://ui.adsabs.harvard.edu/abs/2006A%26A...446..747G/abstract>`_.
330
+
331
+ Parameters
332
+ ----------
333
+ central_wavelength
334
+ Wavelength at the center of the spectral window. This function forces the value of the output vector to be
335
+ ``wavelength`` at index ``num_spec // 2 + 1``.
336
+
337
+ dispersion
338
+ Spectrograph dispersion [nm / px]
339
+
340
+ grating_constant
341
+ Grating constant of the spectrograph grating [lines per mm]
342
+
343
+ order
344
+ Spectrograph order
345
+
346
+ incident_light_angle
347
+ Angle of light incident to the spectrograph grating. Often called :math:`\alpha`.
348
+
349
+ num_spec_px
350
+ The length of the output wavelength vector. Defines size and physical limits of the output.
351
+
352
+ Returns
353
+ -------
354
+ wave_vec
355
+ 1D array of length ``num_spec`` containing the wavelength values described by the input WCS parameterization.
356
+ The units of this array will be nanometers.
357
+ """
358
+ wavelength_parameters = WavelengthParameters(
359
+ crpix=num_spec_px // 2 + 1,
360
+ crval=central_wavelength.to_value(u.nm),
361
+ dispersion=dispersion.to_value(u.nm / u.pix),
362
+ grating_constant=grating_constant.to_value(1 / u.mm),
363
+ order=order,
364
+ incident_light_angle=incident_light_angle.to_value(u.deg),
365
+ cunit="nm",
366
+ )
367
+ header = wavelength_parameters.to_header(axis_num=1)
368
+ wcs = WCS(header)
369
+ input_wavelength_vector = wcs.spectral.pixel_to_world(np.arange(num_spec_px)).to(u.nm)
370
+
371
+ return input_wavelength_vector
372
+
373
+
374
+ def compute_initial_dispersion(
375
+ central_wavelength: Quantity,
376
+ incident_light_angle: Quantity,
377
+ reflected_light_angle: Quantity,
378
+ lens_parameters: list[Quantity],
379
+ pixel_pitch: Quantity,
380
+ ) -> Quantity:
381
+ r"""
382
+ Compute the dispersion (:math:`d\,\lambda/d\, px`) given the spectrograph setup.
383
+
384
+ The dispersion is given via
385
+
386
+ .. math::
387
+ d\,\lambda / d\, px = \frac{p \lambda_0 \cos\beta}{f (\sin\alpha + \sin\beta)}
388
+
389
+ where :math:`p` is the pixel pitch (microns per pix), :math:`\lambda_0` is the central wavelength, :math:`f` is the
390
+ camera focal length, and :math:`\alpha` and :math:`\beta` are the incident and reflected light angles, respectively.
391
+ :math:`\lambda_0`, :math:`\alpha`, and :math:`\beta` are taken from input headers, while :math:`f` and :math:`p` are
392
+ pipeline parameters.
393
+
394
+ Parameters
395
+ ----------
396
+ central_wavelength
397
+ Wavelength of the center of the spectral window.
398
+
399
+ incident_light_angle
400
+ Angle of light incident to the spectrograph grating. Often called :math:`\alpha`.
401
+
402
+ reflected_light_angle
403
+ Angle of light reflected from spectrograph grating. Often called :math:`\beta`.
404
+
405
+ lens_parameters
406
+ Parameterization of lens focal length as zero, first, and second orders of wavelength. If the total focal
407
+ length of the lens is :math:`f = a_0 + a_1\lambda + a_2\lambda^2` then this list is :math:`[a_0, a_1, a_2]`.
408
+
409
+ pixel_pitch
410
+ The physical size of a single pixel
411
+
412
+ Returns
413
+ -------
414
+ dispersion
415
+ The computed dispersion in units of nm / px
416
+ """
417
+ camera_focal_length = lens_parameters[0] + central_wavelength * (
418
+ lens_parameters[1] + central_wavelength * lens_parameters[2]
419
+ )
420
+ logger.info(f"{camera_focal_length = !s}")
421
+
422
+ linear_dispersion = (
423
+ camera_focal_length
424
+ * (np.sin(incident_light_angle) + np.sin(reflected_light_angle))
425
+ / (np.cos(reflected_light_angle) * central_wavelength)
426
+ )
427
+
428
+ dispersion = pixel_pitch / linear_dispersion
429
+
430
+ return dispersion.to(u.nm / u.pix)
@@ -185,6 +185,7 @@ class VispWriteL1Frame(WriteL1Frame):
185
185
  ]
186
186
  wavelength_axis = axis_types.index("AWAV") + 1 # FITS axis numbering is 1-based, not 0
187
187
  wavelength_unit = header[f"CUNIT{wavelength_axis}"]
188
+
188
189
  minimum = header[f"CRVAL{wavelength_axis}"] - (
189
190
  header[f"CRPIX{wavelength_axis}"] * header[f"CDELT{wavelength_axis}"]
190
191
  )
@@ -192,6 +193,7 @@ class VispWriteL1Frame(WriteL1Frame):
192
193
  (header[f"NAXIS{wavelength_axis}"] - header[f"CRPIX{wavelength_axis}"])
193
194
  * header[f"CDELT{wavelength_axis}"]
194
195
  )
196
+
195
197
  return WavelengthRange(
196
198
  min=u.Quantity(minimum, unit=wavelength_unit),
197
199
  max=u.Quantity(maximum, unit=wavelength_unit),
@@ -190,6 +190,17 @@ class VispInputDatasetParameterValues:
190
190
  visp_wavecal_init_resolving_power: int = 150000
191
191
  visp_wavecal_init_straylight_fraction: float = 0.2
192
192
  visp_wavecal_init_opacity_factor: float = 5.0
193
+ visp_wavecal_crval_bounds_px: float = 7
194
+ visp_wavecal_dispersion_bounds_fraction: float = 0.02
195
+ visp_wavecal_incident_light_angle_bounds_deg: float = 0.1
196
+ visp_wavecal_fit_kwargs: dict[str, Any] = field(
197
+ default_factory=lambda: {
198
+ "method": "differential_evolution",
199
+ "init": "halton",
200
+ "popsize": 1,
201
+ "tol": 1e-10,
202
+ }
203
+ )
193
204
  visp_polcal_spatial_median_filter_width_px: int = 10
194
205
  visp_polcal_num_spatial_bins: int = 10
195
206
  visp_polcal_demod_spatial_smooth_fit_order: int = 17
@@ -37,6 +37,7 @@ class VispHeaders(Spec122Dataset):
37
37
  grating_constant: float = 316.0,
38
38
  grating_angle: float = -69.9,
39
39
  arm_position: float = -4.0,
40
+ swap_wcs_axes: bool = False,
40
41
  **kwargs,
41
42
  ):
42
43
  super().__init__(
@@ -46,6 +47,7 @@ class VispHeaders(Spec122Dataset):
46
47
  instrument=instrument,
47
48
  **kwargs,
48
49
  )
50
+ self.swap_wcs_axes = swap_wcs_axes
49
51
  self.add_constant_key("VISP_001", arm_id)
50
52
  self.add_constant_key("WAVELNTH", 656.30)
51
53
  self.add_constant_key("VISP_010", num_modstates_header_value)
@@ -56,11 +58,9 @@ class VispHeaders(Spec122Dataset):
56
58
  self.add_constant_key("DKIST011", ip_start_time)
57
59
  self.add_constant_key("DKIST012", ip_end_time)
58
60
  self.add_constant_key("FILE_ID", uuid.uuid4().hex)
59
-
60
61
  self.add_constant_key("VISP_002", arm_position)
61
62
  self.add_constant_key("VISP_013", grating_constant)
62
63
  self.add_constant_key("VISP_015", grating_angle)
63
-
64
64
  self.num_modstates_header_value = num_modstates_header_value
65
65
  self.add_constant_key("CAM__001", "camera_id")
66
66
  self.add_constant_key("CAM__002", "camera_name")
@@ -81,13 +81,29 @@ class VispHeaders(Spec122Dataset):
81
81
  def fits_wcs(self):
82
82
  w = WCS(naxis=self.array_ndim)
83
83
  w.wcs.crpix = self.array_shape[2] / 2, self.array_shape[1] / 2, 1
84
- w.wcs.crval = 0, 656.30, 0
85
- w.wcs.cdelt = 1, 0.2, 1
86
- w.wcs.cunit = "arcsec", "nm", "arcsec"
87
- w.wcs.ctype = "HPLT-TAN", "AWAV", "HPLN-TAN"
84
+ if self.swap_wcs_axes:
85
+ w.wcs.crval = 656.30, 0, 0
86
+ w.wcs.cdelt = 0.2, 1, 1
87
+ w.wcs.cunit = "nm", "arcsec", "arcsec"
88
+ w.wcs.ctype = "AWAV", "HPLT-TAN", "HPLN-TAN"
89
+ else:
90
+ w.wcs.crval = 0, 656.30, 0
91
+ w.wcs.cdelt = 1, 0.2, 1
92
+ w.wcs.cunit = "arcsec", "nm", "arcsec"
93
+ w.wcs.ctype = "HPLT-TAN", "AWAV", "HPLN-TAN"
88
94
  w.wcs.pc = np.identity(self.array_ndim)
89
95
  return w
90
96
 
97
+ @key_function(
98
+ "CRPIX<n>A",
99
+ "CRVAL<n>A",
100
+ "CDELT<n>A",
101
+ "CUNIT<n>A",
102
+ "CTYPE<n>A",
103
+ )
104
+ def alternate_wcs_keys(self, key: str):
105
+ return self.fits_wcs.to_header()[key.removesuffix("A")]
106
+
91
107
 
92
108
  class VispHeadersInputDarkFrames(VispHeaders):
93
109
  def __init__(
@@ -19,6 +19,7 @@ from dkist_processing_visp.tasks.geometric import GeometricCalibration
19
19
  from dkist_processing_visp.tasks.instrument_polarization import InstrumentPolarizationCalibration
20
20
  from dkist_processing_visp.tasks.lamp import LampCalibration
21
21
  from dkist_processing_visp.tasks.solar import SolarCalibration
22
+ from dkist_processing_visp.tasks.wavelength_calibration import WavelengthCalibration
22
23
  from dkist_processing_visp.tests.conftest import VispInputDatasetParameterValues
23
24
  from dkist_processing_visp.tests.local_trial_workflows.local_trial_helpers import LoadBackgroundCal
24
25
  from dkist_processing_visp.tests.local_trial_workflows.local_trial_helpers import LoadDarkCal
@@ -27,6 +28,9 @@ from dkist_processing_visp.tests.local_trial_workflows.local_trial_helpers impor
27
28
  from dkist_processing_visp.tests.local_trial_workflows.local_trial_helpers import LoadInstPolCal
28
29
  from dkist_processing_visp.tests.local_trial_workflows.local_trial_helpers import LoadLampCal
29
30
  from dkist_processing_visp.tests.local_trial_workflows.local_trial_helpers import LoadSolarCal
31
+ from dkist_processing_visp.tests.local_trial_workflows.local_trial_helpers import (
32
+ LoadWavelengthCalibration,
33
+ )
30
34
  from dkist_processing_visp.tests.local_trial_workflows.local_trial_helpers import (
31
35
  ParseCalOnlyL0InputData,
32
36
  )
@@ -37,6 +41,9 @@ from dkist_processing_visp.tests.local_trial_workflows.local_trial_helpers impor
37
41
  from dkist_processing_visp.tests.local_trial_workflows.local_trial_helpers import SaveInstPolCal
38
42
  from dkist_processing_visp.tests.local_trial_workflows.local_trial_helpers import SaveLampCal
39
43
  from dkist_processing_visp.tests.local_trial_workflows.local_trial_helpers import SaveSolarCal
44
+ from dkist_processing_visp.tests.local_trial_workflows.local_trial_helpers import (
45
+ SaveWavelengthCalibration,
46
+ )
40
47
  from dkist_processing_visp.tests.local_trial_workflows.local_trial_helpers import SetNumModstates
41
48
  from dkist_processing_visp.tests.local_trial_workflows.local_trial_helpers import SetObserveExpTime
42
49
  from dkist_processing_visp.tests.local_trial_workflows.local_trial_helpers import (
@@ -112,6 +119,7 @@ def main(
112
119
  load_lamp: bool = False,
113
120
  load_geometric: bool = False,
114
121
  load_solar: bool = False,
122
+ load_wavelength_calibration: bool = False,
115
123
  load_inst_pol: bool = False,
116
124
  dummy_wavelength: float = 630.0,
117
125
  ):
@@ -172,6 +180,12 @@ def main(
172
180
  manual_processing_run.run_task(task=SolarCalibration)
173
181
  manual_processing_run.run_task(task=SaveSolarCal)
174
182
 
183
+ if load_wavelength_calibration:
184
+ manual_processing_run.run_task(task=LoadWavelengthCalibration)
185
+ else:
186
+ manual_processing_run.run_task(task=WavelengthCalibration)
187
+ manual_processing_run.run_task(task=SaveWavelengthCalibration)
188
+
175
189
  if load_inst_pol:
176
190
  manual_processing_run.run_task(task=LoadInstPolCal)
177
191
  else:
@@ -241,6 +255,12 @@ if __name__ == "__main__":
241
255
  help="Load solar calibration from previously saved run",
242
256
  action="store_true",
243
257
  )
258
+ parser.add_argument(
259
+ "-W",
260
+ "--load-wavelength-calibration",
261
+ help="Load wavelength calibration solution from previously saved run",
262
+ action="store_true",
263
+ )
244
264
  parser.add_argument(
245
265
  "-P",
246
266
  "--load-inst-pol",
@@ -261,6 +281,7 @@ if __name__ == "__main__":
261
281
  load_lamp=args.load_lamp,
262
282
  load_geometric=args.load_geometric,
263
283
  load_solar=args.load_solar,
284
+ load_wavelength_calibration=args.load_wavelength_calibration,
264
285
  load_inst_pol=args.load_inst_pol,
265
286
  )
266
287
  )
@@ -32,6 +32,7 @@ from dkist_processing_visp.tasks.lamp import LampCalibration
32
32
  from dkist_processing_visp.tasks.science import ScienceCalibration
33
33
  from dkist_processing_visp.tasks.solar import SolarCalibration
34
34
  from dkist_processing_visp.tasks.visp_base import VispTaskBase
35
+ from dkist_processing_visp.tasks.wavelength_calibration import WavelengthCalibration
35
36
  from dkist_processing_visp.tasks.write_l1 import VispWriteL1Frame
36
37
  from dkist_processing_visp.tests.conftest import VispInputDatasetParameterValues
37
38
  from dkist_processing_visp.tests.local_trial_workflows.local_trial_helpers import LoadBackgroundCal
@@ -45,6 +46,9 @@ from dkist_processing_visp.tests.local_trial_workflows.local_trial_helpers impor
45
46
  LoadPolcalAsScience,
46
47
  )
47
48
  from dkist_processing_visp.tests.local_trial_workflows.local_trial_helpers import LoadSolarCal
49
+ from dkist_processing_visp.tests.local_trial_workflows.local_trial_helpers import (
50
+ LoadWavelengthCalibration,
51
+ )
48
52
  from dkist_processing_visp.tests.local_trial_workflows.local_trial_helpers import (
49
53
  ParseCalOnlyL0InputData,
50
54
  )
@@ -59,6 +63,9 @@ from dkist_processing_visp.tests.local_trial_workflows.local_trial_helpers impor
59
63
  SavePolcalAsScience,
60
64
  )
61
65
  from dkist_processing_visp.tests.local_trial_workflows.local_trial_helpers import SaveSolarCal
66
+ from dkist_processing_visp.tests.local_trial_workflows.local_trial_helpers import (
67
+ SaveWavelengthCalibration,
68
+ )
62
69
  from dkist_processing_visp.tests.local_trial_workflows.local_trial_helpers import SetAxesTypes
63
70
  from dkist_processing_visp.tests.local_trial_workflows.local_trial_helpers import (
64
71
  SetCadenceConstants,
@@ -224,6 +231,7 @@ def main(
224
231
  load_lamp: bool = False,
225
232
  load_geometric: bool = False,
226
233
  load_solar: bool = False,
234
+ load_wavelength_calibration: bool = False,
227
235
  load_inst_pol: bool = False,
228
236
  load_polcal_as_science: bool = False,
229
237
  load_calibrated_data: bool = False,
@@ -288,6 +296,12 @@ def main(
288
296
  manual_processing_run.run_task(task=SolarCalibration)
289
297
  manual_processing_run.run_task(task=SaveSolarCal)
290
298
 
299
+ if load_wavelength_calibration:
300
+ manual_processing_run.run_task(task=LoadWavelengthCalibration)
301
+ else:
302
+ manual_processing_run.run_task(task=WavelengthCalibration)
303
+ manual_processing_run.run_task(task=SaveWavelengthCalibration)
304
+
291
305
  if load_inst_pol:
292
306
  manual_processing_run.run_task(task=LoadInstPolCal)
293
307
  else:
@@ -382,6 +396,12 @@ if __name__ == "__main__":
382
396
  help="Load solar calibration from previously saved run",
383
397
  action="store_true",
384
398
  )
399
+ parser.add_argument(
400
+ "-W",
401
+ "--load-wavelength-calibration",
402
+ help="Load wavelength calibration solution from previously saved run",
403
+ action="store_true",
404
+ )
385
405
  parser.add_argument(
386
406
  "-P",
387
407
  "--load-inst-pol",
@@ -413,6 +433,7 @@ if __name__ == "__main__":
413
433
  load_lamp=args.load_lamp,
414
434
  load_geometric=args.load_geometric,
415
435
  load_solar=args.load_solar,
436
+ load_wavelength_calibration=args.load_wavelength_calibration,
416
437
  load_inst_pol=args.load_inst_pol,
417
438
  load_polcal_as_science=args.load_polcal_as_science,
418
439
  load_calibrated_data=args.load_calibrated_data,