dkist-processing-visp 2.20.14__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 (73) 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 +61 -20
  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 -24
  7. dkist_processing_visp/models/tags.py +22 -1
  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 +4 -2
  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 +24 -14
  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 +128 -18
  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 +50 -17
  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 +4 -3
  34. dkist_processing_visp/tasks/write_l1.py +38 -10
  35. dkist_processing_visp/tests/conftest.py +145 -47
  36. dkist_processing_visp/tests/header_models.py +157 -20
  37. dkist_processing_visp/tests/local_trial_workflows/l0_cals_only.py +21 -78
  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 +387 -0
  40. dkist_processing_visp/tests/local_trial_workflows/l0_to_l1.py +18 -75
  41. dkist_processing_visp/tests/local_trial_workflows/local_trial_helpers.py +346 -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 +51 -44
  45. dkist_processing_visp/tests/test_dark.py +4 -3
  46. dkist_processing_visp/tests/test_downsample.py +1 -0
  47. dkist_processing_visp/tests/test_fits_access.py +43 -0
  48. dkist_processing_visp/tests/test_geometric.py +45 -4
  49. dkist_processing_visp/tests/test_instrument_polarization.py +72 -9
  50. dkist_processing_visp/tests/test_lamp.py +22 -26
  51. dkist_processing_visp/tests/test_make_movie_frames.py +4 -4
  52. dkist_processing_visp/tests/test_map_repeats.py +3 -1
  53. dkist_processing_visp/tests/test_parameters.py +122 -21
  54. dkist_processing_visp/tests/test_parse.py +164 -18
  55. dkist_processing_visp/tests/test_quality.py +3 -4
  56. dkist_processing_visp/tests/test_science.py +113 -15
  57. dkist_processing_visp/tests/test_solar.py +318 -99
  58. dkist_processing_visp/tests/test_visp_constants.py +38 -8
  59. dkist_processing_visp/tests/test_workflows.py +1 -0
  60. dkist_processing_visp/tests/test_write_l1.py +22 -3
  61. dkist_processing_visp/workflows/__init__.py +1 -0
  62. dkist_processing_visp/workflows/l0_processing.py +10 -3
  63. dkist_processing_visp/workflows/trial_workflows.py +8 -2
  64. dkist_processing_visp-5.1.1.dist-info/METADATA +552 -0
  65. dkist_processing_visp-5.1.1.dist-info/RECORD +94 -0
  66. {dkist_processing_visp-2.20.14.dist-info → dkist_processing_visp-5.1.1.dist-info}/WHEEL +1 -1
  67. docs/conf.py +5 -1
  68. docs/gain_correction.rst +52 -44
  69. docs/science_calibration.rst +7 -0
  70. dkist_processing_visp/tasks/mixin/line_zones.py +0 -115
  71. dkist_processing_visp-2.20.14.dist-info/METADATA +0 -196
  72. dkist_processing_visp-2.20.14.dist-info/RECORD +0 -89
  73. {dkist_processing_visp-2.20.14.dist-info → dkist_processing_visp-5.1.1.dist-info}/top_level.txt +0 -0
@@ -1,25 +1,124 @@
1
1
  """ViSP solar calibration task. See :doc:`this page </gain_correction>` for more information."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import partial
6
+ from typing import Callable
7
+
8
+ import astropy.units as u
2
9
  import numpy as np
3
10
  import scipy.ndimage as spnd
4
- import scipy.optimize as spo
11
+ from astropy.time import Time
12
+ from astropy.units import Quantity
13
+ from astropy.wcs import WCS
14
+ from dkist_processing_common.codecs.asdf import asdf_encoder
5
15
  from dkist_processing_common.codecs.fits import fits_access_decoder
6
16
  from dkist_processing_common.codecs.fits import fits_array_decoder
7
17
  from dkist_processing_common.codecs.fits import fits_array_encoder
18
+ from dkist_processing_common.models.dkist_location import location_of_dkist
8
19
  from dkist_processing_common.models.task_name import TaskName
9
20
  from dkist_processing_common.tasks.mixin.quality import QualityMixin
10
21
  from dkist_processing_math.arithmetic import divide_arrays_by_array
11
22
  from dkist_processing_math.arithmetic import subtract_array_from_arrays
12
23
  from dkist_processing_math.statistics import average_numpy_arrays
13
24
  from dkist_service_configuration.logging import logger
14
-
25
+ from lmfit.parameter import Parameters
26
+ from sklearn.linear_model import RANSACRegressor
27
+ from sklearn.pipeline import Pipeline
28
+ from sklearn.pipeline import make_pipeline
29
+ from sklearn.preprocessing import PolynomialFeatures
30
+ from sklearn.preprocessing import RobustScaler
31
+ from solar_wavelength_calibration import AngleBoundRange
32
+ from solar_wavelength_calibration import Atlas
33
+ from solar_wavelength_calibration import BoundsModel
34
+ from solar_wavelength_calibration import DispersionBoundRange
35
+ from solar_wavelength_calibration import FitFlagsModel
36
+ from solar_wavelength_calibration import LengthBoundRange
37
+ from solar_wavelength_calibration import UnitlessBoundRange
38
+ from solar_wavelength_calibration import WavelengthCalibrationFitter
39
+ from solar_wavelength_calibration import WavelengthCalibrationParameters
40
+ from solar_wavelength_calibration.fitter.wavelength_fitter import FitResult
41
+ from solar_wavelength_calibration.fitter.wavelength_fitter import WavelengthParameters
42
+ from solar_wavelength_calibration.fitter.wavelength_fitter import calculate_initial_crval_guess
43
+ from sunpy.coordinates import HeliocentricInertial
44
+
45
+ from dkist_processing_visp.models.metric_code import VispMetricCode
15
46
  from dkist_processing_visp.models.tags import VispTag
16
47
  from dkist_processing_visp.parsers.visp_l0_fits_access import VispL0FitsAccess
17
48
  from dkist_processing_visp.tasks.mixin.beam_access import BeamAccessMixin
18
49
  from dkist_processing_visp.tasks.mixin.corrections import CorrectionsMixin
19
- from dkist_processing_visp.tasks.mixin.line_zones import LineZonesMixin
20
50
  from dkist_processing_visp.tasks.visp_base import VispTaskBase
21
51
 
22
- __all__ = ["SolarCalibration"]
52
+ __all__ = [
53
+ "SolarCalibration",
54
+ "WavelengthCalibrationParametersWithContinuum",
55
+ "polynomial_continuum_model",
56
+ "compute_order",
57
+ "compute_initial_dispersion",
58
+ "compute_doppler_velocity",
59
+ "compute_input_wavelength_vector",
60
+ ]
61
+
62
+
63
+ class WavelengthCalibrationParametersWithContinuum(WavelengthCalibrationParameters):
64
+ """
65
+ Subclass of `~solar_wavelength_calibration.WavelengthCalibrationParameters` that adds a polynomial continuum parameterization.
66
+
67
+ The order of the continuum polynomial is set with the new ``continuum_poly_fit_oder`` model field. The `lmfit_parameters`
68
+ property now adds a set of parameters that represent the polynomial coefficients.
69
+ """
70
+
71
+ continuum_poly_fit_order: int
72
+ normalized_abscissa: np.ndarray
73
+ zeroth_order_continuum_coefficient: float
74
+
75
+ @property
76
+ def continuum_function(self) -> Callable[[np.ndarray, Parameters], np.ndarray]:
77
+ """Return a partial function of `polynomial_continuum_model` pre-loaded with the fit order and normalized abscissa."""
78
+ return partial(
79
+ polynomial_continuum_model,
80
+ fit_order=self.continuum_poly_fit_order,
81
+ abscissa=self.normalized_abscissa,
82
+ )
83
+
84
+ @property
85
+ def lmfit_parameters(self) -> Parameters:
86
+ """
87
+ Add continuum polynomial coefficient parameters to the standard `~solar_wavelength_calibration.WavelengthCalibrationParameters.lmfit_parameters`.
88
+
89
+ Each coefficient gets its own parameter called ``poly_coeff_{o:02n}``. The 0th order (i.e., constant continuum
90
+ level) coefficient has bounds [0.7, 1.3] and all higher-order coefficients have bounds [-1, 1].
91
+ """
92
+ # NOTE: We set `vary=True` because otherwise we just wouldn't use this class
93
+ # We set the bounds here because it's easier that defining a custom `BoundsModel` class that can
94
+ # dynamically create the required number of "poly_coeff_{o:02n}" fields. Sorry if this bites you!
95
+ params = super().lmfit_parameters
96
+ for o in range(self.continuum_poly_fit_order + 1):
97
+ # `np.polyval` uses its input coefficient list "backwards", so `poly_coeff_{self.continuum_poly_fit_order}`
98
+ # is the 0th order polynomial term.
99
+ params.add(
100
+ f"poly_coeff_{o:02n}",
101
+ vary=True,
102
+ value=(
103
+ self.zeroth_order_continuum_coefficient
104
+ if o == self.continuum_poly_fit_order
105
+ else 0
106
+ ),
107
+ min=-1,
108
+ max=1,
109
+ )
110
+
111
+ params[f"poly_coeff_{self.continuum_poly_fit_order:02n}"].min = (
112
+ self.zeroth_order_continuum_coefficient * 0.5
113
+ )
114
+ params[f"poly_coeff_{self.continuum_poly_fit_order:02n}"].max = (
115
+ self.zeroth_order_continuum_coefficient * 1.5
116
+ )
117
+
118
+ # Remove the default continuum parameterization
119
+ del params["continuum_level"]
120
+
121
+ return params
23
122
 
24
123
 
25
124
  class SolarCalibration(
@@ -27,18 +126,19 @@ class SolarCalibration(
27
126
  BeamAccessMixin,
28
127
  CorrectionsMixin,
29
128
  QualityMixin,
30
- LineZonesMixin,
31
129
  ):
32
130
  """
33
131
  Task class for generating Solar Gain images for each beam/modstate.
34
132
 
35
133
  Parameters
36
134
  ----------
37
- recipe_run_id : int
135
+ recipe_run_id
38
136
  id of the recipe run used to identify the workflow run this task is part of
39
- workflow_name : str
137
+
138
+ workflow_name
40
139
  name of the workflow to which this instance of the task belongs
41
- workflow_version : str
140
+
141
+ workflow_version
42
142
  version of the workflow to which this instance of the task belongs
43
143
 
44
144
  """
@@ -49,127 +149,162 @@ class SolarCalibration(
49
149
  """
50
150
  For each beam.
51
151
 
52
- For each modstate:
53
- - Do dark, background, lamp, and geometric corrections
54
- - Compute the characteristic spectra
55
- - Re-apply the spectral curvature to the characteristic spectra
56
- - Re-apply angle and state offset distortions to the characteristic spectra
57
- - Remove the distorted characteristic solar spectra from the original spectra
58
- - Write master solar gain
59
-
60
- Returns
61
- -------
62
- None
63
-
152
+ #. Do dark, background, lamp, and geometric corrections
153
+ #. Compute an initial separation of low-order spectral vignette signal from the true solar spectrum
154
+ #. Compute a 2D vignette signal by fitting solar spectrum residuals along the slit
155
+ #. Remove the 2D vignette signal from the averaged gain array
156
+ #. Compute the characteristic spectra
157
+ #. Re-apply the spectral curvature to the characteristic spectra
158
+ #. Re-apply angle and state offset distortions to the characteristic spectra
159
+ #. Remove the distorted characteristic solar spectra from the original, dark-corrected spectra
160
+ #. Write final gain to disk
64
161
  """
65
162
  for beam in range(1, self.constants.num_beams + 1):
66
163
 
67
- pre_equalized_gain_dict = dict()
68
-
69
- for modstate in range(1, self.constants.num_modstates + 1):
70
- apm_str = f"{beam = } and {modstate = }"
71
- with self.apm_processing_step(f"Initial corrections for {apm_str}"):
72
- self.do_initial_corrections(beam=beam, modstate=modstate)
73
-
74
- with self.apm_processing_step(f"Computing characteristic spectra for {apm_str}"):
75
- char_spec = self.compute_characteristic_spectra(beam=beam, modstate=modstate)
76
- self.write(
77
- data=char_spec,
78
- encoder=fits_array_encoder,
79
- tags=[VispTag.debug(), VispTag.frame()],
80
- relative_path=f"DEBUG_SC_CHAR_SPEC_BEAM_{beam}_MODSTATE_{modstate}.dat",
81
- overwrite=True,
82
- )
164
+ apm_str = f"{beam = }"
165
+ with self.telemetry_span(f"Initial corrections for {apm_str}"):
166
+ self.do_initial_corrections(beam=beam)
83
167
 
84
- with self.apm_processing_step(
85
- f"Re-distorting characteristic spectra for {apm_str}"
86
- ):
87
- spec_shift = next(
88
- self.read(
89
- tags=[
90
- VispTag.intermediate_frame(beam=beam),
91
- VispTag.task_geometric_spectral_shifts(),
92
- ],
93
- decoder=fits_array_decoder,
94
- )
95
- )
96
- redistorted_char_spec = next(
97
- self.corrections_remove_spec_geometry(
98
- arrays=char_spec, spec_shift=-1 * spec_shift
99
- )
100
- )
101
- self.write(
102
- data=redistorted_char_spec,
103
- encoder=fits_array_encoder,
104
- tags=[VispTag.debug(), VispTag.frame()],
105
- relative_path=f"DEBUG_SC_CHAR_DISTORT_BEAM_{beam}_MODSTATE_{modstate}.dat",
106
- overwrite=True,
107
- )
168
+ with self.telemetry_span(f"Fit atlas with continuum for {apm_str}"):
169
+ representative_spectrum = self.get_representative_spectrum(beam)
108
170
 
109
- with self.apm_processing_step(f"Re-shifting characteristic spectra for {apm_str}"):
110
- reshifted_char_spec = self.distort_characteristic_spectra(
111
- char_spec=redistorted_char_spec, beam=beam, modstate=modstate
112
- )
113
- self.write(
114
- data=reshifted_char_spec,
115
- encoder=fits_array_encoder,
171
+ self.write(
172
+ data=representative_spectrum,
173
+ tags=[VispTag.debug(), VispTag.beam(beam), VispTag.task("REP_SPEC")],
174
+ encoder=fits_array_encoder,
175
+ )
176
+
177
+ logger.info("Deriving values from instrument configuration")
178
+ order = compute_order(
179
+ central_wavelength=self.constants.wavelength * u.nm,
180
+ incident_light_angle=self.constants.incident_light_angle_deg,
181
+ reflected_light_angle=self.constants.reflected_light_angle_deg,
182
+ grating_constant=self.constants.grating_constant_inverse_mm,
183
+ )
184
+
185
+ initial_dispersion = compute_initial_dispersion(
186
+ central_wavelength=self.constants.wavelength * u.nm,
187
+ incident_light_angle=self.constants.incident_light_angle_deg,
188
+ reflected_light_angle=self.constants.reflected_light_angle_deg,
189
+ lens_parameters=self.parameters.wavecal_camera_lens_parameters,
190
+ pixel_pitch=self.parameters.wavecal_pixel_pitch_micron_per_pix,
191
+ )
192
+
193
+ doppler_velocity = compute_doppler_velocity(
194
+ time_of_observation=self.constants.solar_gain_ip_start_time
195
+ )
196
+
197
+ self._log_wavecal_parameters(
198
+ dispersion=initial_dispersion, order=order, doppler_velocity=doppler_velocity
199
+ )
200
+
201
+ fit_result = self.fit_initial_vignette(
202
+ representative_spectrum=representative_spectrum,
203
+ dispersion=initial_dispersion,
204
+ spectral_order=order,
205
+ doppler_velocity=doppler_velocity,
206
+ )
207
+
208
+ first_vignette_estimation = self.compute_initial_vignette_estimation(
209
+ beam=beam,
210
+ representative_spectrum=representative_spectrum,
211
+ continuum=fit_result.best_fit_continuum,
212
+ )
213
+
214
+ self.write(
215
+ data=first_vignette_estimation,
216
+ tags=[VispTag.debug(), VispTag.beam(beam), VispTag.task("FIRST_VIGNETTE_EST")],
217
+ encoder=fits_array_encoder,
218
+ )
219
+
220
+ with self.telemetry_span(f"Estimate 2D vignetting signature for {apm_str}"):
221
+ final_vignette = self.compute_final_vignette_estimate(
222
+ init_vignette_correction=first_vignette_estimation
223
+ )
224
+ vignette_corrected_gain = self.geo_corrected_beam_data(beam=beam) / final_vignette
225
+
226
+ self.write(
227
+ data=final_vignette,
228
+ tags=[VispTag.debug(), VispTag.beam(beam), VispTag.task("FINAL_VIGNETTE")],
229
+ encoder=fits_array_encoder,
230
+ )
231
+ self.write(
232
+ data=vignette_corrected_gain,
233
+ tags=[VispTag.debug(), VispTag.beam(beam), VispTag.task("VIGNETTE_CORR")],
234
+ encoder=fits_array_encoder,
235
+ )
236
+
237
+ with self.telemetry_span(f"Save vignette quality metrics for {apm_str}"):
238
+ self.record_vignette_quality_metrics(
239
+ beam=beam,
240
+ representative_spectrum=representative_spectrum,
241
+ fit_result=fit_result,
242
+ vignette_corrected_gain=vignette_corrected_gain,
243
+ )
244
+
245
+ with self.telemetry_span(f"Compute characteristic spectra for {apm_str}"):
246
+ char_spec = self.compute_characteristic_spectra(vignette_corrected_gain)
247
+ self.write(
248
+ data=char_spec,
249
+ encoder=fits_array_encoder,
250
+ tags=[VispTag.debug(), VispTag.frame()],
251
+ relative_path=f"DEBUG_SC_CHAR_SPEC_BEAM_{beam}.dat",
252
+ overwrite=True,
253
+ )
254
+
255
+ with self.telemetry_span(
256
+ f"Applying spectral shifts to characteristic spectra for {apm_str}"
257
+ ):
258
+ spec_shift = next(
259
+ self.read(
116
260
  tags=[
117
- VispTag.beam(beam),
118
- VispTag.modstate(modstate),
119
- VispTag.task("CHAR_SPEC_DISTORT_SHIFT"),
261
+ VispTag.intermediate_frame(beam=beam),
262
+ VispTag.task_geometric_spectral_shifts(),
120
263
  ],
121
- relative_path=f"DEBUG_SC_CHAR_SPEC_DISTORT_SHIFT_BEAM_{beam}_MODSTATE_{modstate}.dat",
122
- overwrite=True,
123
- )
124
-
125
- with self.apm_processing_step(
126
- f"Refining characteristic spectral shifts for {apm_str}"
127
- ):
128
- refined_char_spec = self.refine_gain_shifts(
129
- char_spec=reshifted_char_spec, beam=beam, modstate=modstate
130
- )
131
- self.write(
132
- data=refined_char_spec,
133
- encoder=fits_array_encoder,
134
- tags=[VispTag.debug(), VispTag.frame()],
135
- relative_path=f"DEBUG_SC_CHAR_SPEC_REFINE_BEAM_{beam}_MODSTATE_{modstate}.dat",
136
- overwrite=True,
264
+ decoder=fits_array_decoder,
137
265
  )
138
-
139
- with self.apm_processing_step(f"Removing solar signal from {apm_str}"):
140
- gain = self.remove_solar_signal(
141
- char_solar_spectra=refined_char_spec, beam=beam, modstate=modstate
266
+ )
267
+ redistorted_char_spec = next(
268
+ self.corrections_remove_spec_geometry(
269
+ arrays=char_spec, spec_shift=-1 * spec_shift
142
270
  )
271
+ )
272
+ self.write(
273
+ data=redistorted_char_spec,
274
+ encoder=fits_array_encoder,
275
+ tags=[VispTag.debug(), VispTag.frame()],
276
+ relative_path=f"DEBUG_SC_CHAR_DISTORT_BEAM_{beam}.dat",
277
+ overwrite=True,
278
+ )
143
279
 
144
- with self.apm_processing_step(f"Masking hairlines from {apm_str}"):
145
- gain = self.corrections_mask_hairlines(gain)
146
-
280
+ with self.telemetry_span(f"Re-distorting characteristic spectra for {apm_str}"):
281
+ reshifted_char_spec = self.distort_characteristic_spectra(
282
+ char_spec=redistorted_char_spec, beam=beam
283
+ )
147
284
  self.write(
148
- data=gain,
285
+ data=reshifted_char_spec,
149
286
  encoder=fits_array_encoder,
150
287
  tags=[
151
- VispTag.debug(),
152
- VispTag.frame(),
288
+ VispTag.beam(beam),
289
+ VispTag.task("CHAR_SPEC_DISTORT_SHIFT"),
153
290
  ],
154
- relative_path=f"DEBUG_SC_PRE_EQ_SOLAR_GAIN_BEAM_{beam}_MODSTATE_{modstate}.dat",
291
+ relative_path=f"DEBUG_SC_CHAR_SPEC_DISTORT_SHIFT_BEAM_{beam}.dat",
155
292
  overwrite=True,
156
293
  )
157
294
 
158
- pre_equalized_gain_dict[modstate] = gain
159
-
160
- with self.apm_processing_step(f"Equalizing modstates for {beam = }"):
161
- equalized_gain_dict = self.equalize_modstates(pre_equalized_gain_dict)
162
-
163
- for modstate in range(1, self.constants.num_modstates + 1):
295
+ with self.telemetry_span(f"Removing solar signal from {apm_str}"):
296
+ gain = self.remove_solar_signal(char_solar_spectra=reshifted_char_spec, beam=beam)
164
297
 
165
- final_gain = equalized_gain_dict[modstate]
298
+ with self.telemetry_span(f"Masking hairlines from {apm_str}"):
299
+ final_gain = self.corrections_mask_hairlines(gain)
166
300
 
167
- with self.apm_writing_step(f"Writing solar gain for {beam = } and {modstate = }"):
168
- self.write_solar_gain_calibration(
169
- gain_array=final_gain, beam=beam, modstate=modstate
170
- )
301
+ with self.telemetry_span(f"Writing solar gain for {beam = }"):
302
+ self.write_solar_gain_calibration(
303
+ gain_array=final_gain,
304
+ beam=beam,
305
+ )
171
306
 
172
- with self.apm_processing_step("Computing and logging quality metrics"):
307
+ with self.telemetry_span("Computing and logging quality metrics"):
173
308
  no_of_raw_solar_frames: int = self.scratch.count_all(
174
309
  tags=[
175
310
  VispTag.input(),
@@ -182,84 +317,36 @@ class SolarCalibration(
182
317
  task_type=TaskName.solar_gain.value, total_frames=no_of_raw_solar_frames
183
318
  )
184
319
 
185
- def unshifted_geo_corrected_modstate_data(self, beam: int, modstate: int) -> np.ndarray:
186
- """
187
- Array for a single beam/modstate that has dark, lamp, angle, and state offset corrections.
188
-
189
- Parameters
190
- ----------
191
- beam : int
192
- The beam number for this array
193
-
194
- modstate : int
195
- The modulator state for this array
196
-
197
-
198
- Returns
199
- -------
200
- np.ndarray
201
- Array with dark signal, lamp signal, angle and state offset removed
202
-
203
- """
204
- tags = [
205
- VispTag.intermediate_frame(beam=beam, modstate=modstate),
206
- VispTag.task("SC_GEO_NOSHIFT"),
207
- ]
208
- array_generator = self.read(tags=tags, decoder=fits_array_decoder)
209
- return next(array_generator)
210
-
211
- def geo_corrected_modstate_data(self, beam: int, modstate: int) -> np.ndarray:
320
+ def geo_corrected_beam_data(self, beam: int) -> np.ndarray:
212
321
  """
213
- Array for a single beam/modstate that has dark, lamp, and ALL of the geometric corrects.
322
+ Array for a single beam that has dark, lamp, and ALL of the geometric corrects.
214
323
 
215
324
  Parameters
216
325
  ----------
217
- beam : int
326
+ beam
218
327
  The beam number for this array
219
328
 
220
- modstate : int
221
- The modulator state for this array
222
-
223
-
224
329
  Returns
225
330
  -------
226
- np.ndarray
331
+ Fully corrected array
227
332
  Array with dark signal, and lamp signal removed, and all geometric corrections made
228
333
  """
229
334
  tags = [
230
- VispTag.intermediate_frame(beam=beam, modstate=modstate),
335
+ VispTag.intermediate_frame(beam=beam),
231
336
  VispTag.task("SC_GEO_ALL"),
232
337
  ]
233
338
  array_generator = self.read(tags=tags, decoder=fits_array_decoder)
234
339
  return next(array_generator)
235
340
 
236
- def lamp_corrected_modstate_data(self, beam: int, modstate: int) -> np.ndarray:
237
- """
238
- Array for a single beam/modstate that has dark, background, and lamp gain applied.
239
-
240
- This is used to refine the final shifts in the re-distorted characteristic spectra. Having the lamp gain applied
241
- removes large optical features that would otherwise pollute the match to the characteristic spectra (which has
242
- no optical features).
243
- """
244
- tags = [
245
- VispTag.intermediate_frame(beam=beam, modstate=modstate),
246
- VispTag.task("SC_LAMP_CORR"),
247
- ]
248
- array_generator = self.read(tags=tags, decoder=fits_array_decoder)
249
- return next(array_generator)
250
-
251
- def bg_corrected_modstate_data(self, beam: int, modstate: int) -> np.ndarray:
341
+ def bg_corrected_beam_data(self, beam: int) -> np.ndarray:
252
342
  """
253
- Array for a single beam/modstate that has only has dark and background corrects applied.
343
+ Array for a single beam that has only has dark and background corrects applied.
254
344
 
255
345
  Parameters
256
346
  ----------
257
- beam : int
347
+ beam
258
348
  The beam number for this array
259
349
 
260
- modstate : int
261
- The modulator state for this array
262
-
263
350
 
264
351
  Returns
265
352
  -------
@@ -267,37 +354,25 @@ class SolarCalibration(
267
354
  Array with dark and background signals removed
268
355
  """
269
356
  tags = [
270
- VispTag.intermediate_frame(beam=beam, modstate=modstate),
357
+ VispTag.intermediate_frame(beam=beam),
271
358
  VispTag.task("SC_BG_ONLY"),
272
359
  ]
273
360
  array_generator = self.read(tags=tags, decoder=fits_array_decoder)
274
361
  return next(array_generator)
275
362
 
276
- def do_initial_corrections(self, beam: int, modstate: int) -> None:
363
+ def do_initial_corrections(self, beam: int) -> None:
277
364
  """
278
365
  Do dark, lamp, and geometric corrections for all data that will be used.
279
366
 
280
- At two intermediate points the current arrays are saved because they'll be needed by various helpers:
281
-
282
- SC_BG_ONLY - The solar gain arrays with only a dark and background correction.
283
-
284
- SC_GEO_NOSHIFT - The solar gain arrays after dark, lamp, angle, and state offset correction. In other words,
285
- they do not have spectral curvature removed. These are used to reshift the characteristic
286
- spectra to the original spectral curvature.
367
+ We also save an intermediate product of the average solar gain array with only dark and background corrections.
368
+ This array will later be used to produce the final solar gain. It's task tag is SC_BG_ONLY.
287
369
 
288
370
  Parameters
289
371
  ----------
290
- beam : int
372
+ beam
291
373
  The beam number for this array
292
-
293
- modstate : int
294
- The modulator state for this array
295
-
296
-
297
- Returns
298
- -------
299
- None
300
374
  """
375
+ all_exp_time_arrays = []
301
376
  for readout_exp_time in self.constants.solar_readout_exp_times:
302
377
  dark_array = next(
303
378
  self.read(
@@ -317,15 +392,12 @@ class SolarCalibration(
317
392
  )
318
393
  )
319
394
 
320
- logger.info(
321
- f"Doing dark, background, lamp, and geo corrections for {beam=} and {modstate=}"
322
- )
395
+ logger.info(f"Doing dark, background, lamp, and geo corrections for {beam=}")
323
396
  ## Load frames
324
397
  tags = [
325
398
  VispTag.input(),
326
399
  VispTag.frame(),
327
400
  VispTag.task_solar_gain(),
328
- VispTag.modstate(modstate),
329
401
  VispTag.readout_exp_time(readout_exp_time),
330
402
  ]
331
403
  input_solar_gain_objs = self.read(
@@ -337,140 +409,120 @@ class SolarCalibration(
337
409
  for o in input_solar_gain_objs
338
410
  )
339
411
 
340
- ## Average
341
412
  avg_solar_array = average_numpy_arrays(readout_normalized_arrays)
342
413
 
343
- ## Dark correction
344
414
  dark_corrected_solar_array = subtract_array_from_arrays(
345
415
  arrays=avg_solar_array, array_to_subtract=dark_array
346
416
  )
347
417
 
348
- ## Residual background correction
349
418
  background_corrected_solar_array = next(
350
419
  subtract_array_from_arrays(dark_corrected_solar_array, background_array)
351
420
  )
352
421
 
353
- # Save the only-dark-corr because this will be used to make the final Solar Gain object
354
- self.write(
355
- data=background_corrected_solar_array,
356
- tags=[
357
- VispTag.intermediate_frame(beam=beam, modstate=modstate),
358
- VispTag.task("SC_BG_ONLY"),
359
- ],
360
- encoder=fits_array_encoder,
361
- )
422
+ all_exp_time_arrays.append(background_corrected_solar_array)
362
423
 
363
- ## Lamp correction
364
- lamp_array = next(
365
- self.read(
366
- tags=[
367
- VispTag.intermediate_frame(beam=beam, modstate=modstate),
368
- VispTag.task_lamp_gain(),
369
- ],
370
- decoder=fits_array_decoder,
371
- )
372
- )
373
- lamp_corrected_solar_array = next(
374
- divide_arrays_by_array(
375
- arrays=background_corrected_solar_array, array_to_divide_by=lamp_array
376
- )
377
- )
424
+ avg_dark_bg_corrected_array = average_numpy_arrays(all_exp_time_arrays)
378
425
 
379
- self.write(
380
- data=lamp_corrected_solar_array,
426
+ # Save the only-dark-corr because this will be used to make the final Solar Gain object
427
+ self.write(
428
+ data=avg_dark_bg_corrected_array,
429
+ tags=[
430
+ VispTag.intermediate_frame(beam=beam),
431
+ VispTag.task("SC_BG_ONLY"),
432
+ ],
433
+ encoder=fits_array_encoder,
434
+ )
435
+
436
+ ## Lamp correction
437
+ lamp_array = next(
438
+ self.read(
381
439
  tags=[
382
- VispTag.intermediate_frame(beam=beam, modstate=modstate),
383
- VispTag.task("SC_LAMP_CORR"),
440
+ VispTag.intermediate_frame(beam=beam),
441
+ VispTag.task_lamp_gain(),
384
442
  ],
385
- encoder=fits_array_encoder,
386
- )
387
-
388
- ## Geo correction
389
- angle_array = next(
390
- self.read(
391
- tags=[VispTag.intermediate_frame(beam=beam), VispTag.task_geometric_angle()],
392
- decoder=fits_array_decoder,
393
- )
394
- )
395
- angle = angle_array[0]
396
- state_offset = next(
397
- self.read(
398
- tags=[
399
- VispTag.intermediate_frame(beam=beam, modstate=modstate),
400
- VispTag.task_geometric_offset(),
401
- ],
402
- decoder=fits_array_decoder,
403
- )
443
+ decoder=fits_array_decoder,
404
444
  )
405
- spec_shift = next(
406
- self.read(
407
- tags=[
408
- VispTag.intermediate_frame(beam=beam),
409
- VispTag.task_geometric_spectral_shifts(),
410
- ],
411
- decoder=fits_array_decoder,
412
- )
445
+ )
446
+ lamp_corrected_solar_array = next(
447
+ divide_arrays_by_array(
448
+ arrays=avg_dark_bg_corrected_array, array_to_divide_by=lamp_array
413
449
  )
450
+ )
414
451
 
415
- geo_corrected_array = next(
416
- self.corrections_correct_geometry(lamp_corrected_solar_array, state_offset, angle)
417
- )
418
- # We need unshifted, but geo-corrected arrays for reshifting and normalization
419
- self.write(
420
- data=geo_corrected_array,
421
- tags=[
422
- VispTag.intermediate_frame(beam=beam, modstate=modstate),
423
- VispTag.task("SC_GEO_NOSHIFT"),
424
- ],
425
- encoder=fits_array_encoder,
426
- )
452
+ self.write(
453
+ data=lamp_corrected_solar_array,
454
+ tags=[
455
+ VispTag.debug(),
456
+ VispTag.beam(beam),
457
+ VispTag.task("SC_LAMP_CORR"),
458
+ ],
459
+ encoder=fits_array_encoder,
460
+ )
427
461
 
428
- # Now finish the spectral shift correction
429
- spectral_corrected_array = next(
430
- self.corrections_remove_spec_geometry(geo_corrected_array, spec_shift)
462
+ ## Geo correction
463
+ angle_array = next(
464
+ self.read(
465
+ tags=[VispTag.intermediate_frame(beam=beam), VispTag.task_geometric_angle()],
466
+ decoder=fits_array_decoder,
431
467
  )
432
- self.write(
433
- data=spectral_corrected_array,
468
+ )
469
+ angle = angle_array[0]
470
+
471
+ # Because we have averaged over all modstates, the per-modestate offsets don't matter. Also, we're going to
472
+ # undo these corrections to make the final gain, so the beam2 -> beam1 offset doesn't matter either.
473
+ state_offset = np.array([0.0, 0.0])
474
+ spec_shift = next(
475
+ self.read(
434
476
  tags=[
435
- VispTag.intermediate_frame(beam=beam, modstate=modstate),
436
- VispTag.task("SC_GEO_ALL"),
477
+ VispTag.intermediate_frame(beam=beam),
478
+ VispTag.task_geometric_spectral_shifts(),
437
479
  ],
438
- encoder=fits_array_encoder,
480
+ decoder=fits_array_decoder,
439
481
  )
482
+ )
440
483
 
441
- def compute_characteristic_spectra(self, beam: int, modstate: int) -> np.ndarray:
442
- """
443
- Compute the 2D characteristic spectra via a Gaussian smooth in the spatial dimension.
444
-
445
- A 2D characteristic spectra is needed because the line shape varys along the slit to the degree that a
446
- single, 1D characteristic spectrum will not fully remove the solar lines for all positions in the final gain.
447
-
448
- In this step we also normalize each spatial position to its median value. This removes low-order gradients in
449
- the spatial direction that are known to be caused by imperfect illumination of the Lamp gains (which were used
450
- to correct the data that will become the characteristic spectra).
451
-
452
- Parameters
453
- ----------
454
- beam : int
455
- The beam number for this array
484
+ geo_corrected_array = next(
485
+ self.corrections_correct_geometry(lamp_corrected_solar_array, state_offset, angle)
486
+ )
487
+ self.write(
488
+ data=geo_corrected_array,
489
+ tags=[
490
+ VispTag.debug(),
491
+ VispTag.beam(beam),
492
+ VispTag.task("SC_GEO_NOSHIFT"),
493
+ ],
494
+ encoder=fits_array_encoder,
495
+ )
456
496
 
457
- modstate : int
458
- The modulator state for this array
497
+ # Now finish the spectral shift correction
498
+ spectral_corrected_array = next(
499
+ self.corrections_remove_spec_geometry(geo_corrected_array, spec_shift)
500
+ )
501
+ self.write(
502
+ data=spectral_corrected_array,
503
+ tags=[
504
+ VispTag.intermediate_frame(beam=beam),
505
+ VispTag.task("SC_GEO_ALL"),
506
+ ],
507
+ encoder=fits_array_encoder,
508
+ )
459
509
 
510
+ def get_representative_spectrum(self, beam: int) -> np.ndarray:
511
+ """
512
+ Compute a representative spectrum that will be used for solar atlas and continuum fitting.
460
513
 
461
- Returns
462
- -------
463
- np.ndarray
464
- Characteristic spectra array
514
+ The spectrum is the spatial median of the lamp-and-spectral-shift corrected solar gain image for this beam.
515
+ Prior to computing the spatial median each spatial pixel is normalized by its continuum level so that variations
516
+ in overall scaling as a function of slit position don't skew the median line shapes. The continuum for each
517
+ spatial pixel is estimated from a percentage of the CDF; this percentage is a pipeline parameter.
465
518
  """
466
- spectral_avg_window = self.parameters.solar_spectral_avg_window
467
519
  normalization_percentile = (
468
520
  self.parameters.solar_characteristic_spatial_normalization_percentile
469
521
  )
470
522
  logger.info(
471
- f"Computing characteristic spectra for {beam = } and {modstate = } with {spectral_avg_window = } and {normalization_percentile = }"
523
+ f"Computing representative spectra for {beam = } with {normalization_percentile = }"
472
524
  )
473
- full_spectra = self.geo_corrected_modstate_data(beam=beam, modstate=modstate)
525
+ full_spectra = self.geo_corrected_beam_data(beam=beam)
474
526
 
475
527
  full_spectra = self.corrections_mask_hairlines(full_spectra)
476
528
  # Normalize each spatial pixel by its own percentile. This removes large spatial gradients that are not solar
@@ -479,86 +531,315 @@ class SolarCalibration(
479
531
  full_spectra, normalization_percentile, axis=0
480
532
  )
481
533
 
482
- # size = (1, window) means don't smooth in the spectra dimension
483
- char_spec = spnd.median_filter(normed_spectra, size=(1, spectral_avg_window))
534
+ representative_spectrum = np.nanmedian(normed_spectra, axis=1)
535
+
536
+ return representative_spectrum
537
+
538
+ def fit_initial_vignette(
539
+ self,
540
+ representative_spectrum: np.ndarray,
541
+ dispersion: Quantity,
542
+ spectral_order: int,
543
+ doppler_velocity: Quantity,
544
+ ) -> FitResult:
545
+ """
546
+ Fit a global continuum to a single, representative spectrum.
547
+
548
+ The representative spectrum's continuum is estimated by fitting the spectrum to a solar atlas. The continuum
549
+ is parameterized with a polynomial as seen in `WavelengthCalibrationParametersWithContinuum` and
550
+ `polynomial_continuum_model`.
551
+ """
552
+ atlas = Atlas(self.parameters.wavecal_atlas_download_config)
553
+
554
+ logger.info("Initializing wavecal fit parameters")
555
+ init_parameters = self.initialize_starting_fit_parameters(
556
+ representative_spectrum=representative_spectrum,
557
+ dispersion=dispersion,
558
+ spectral_order=spectral_order,
559
+ doppler_velocity=doppler_velocity,
560
+ atlas=atlas,
561
+ )
562
+
563
+ fitter = WavelengthCalibrationFitter(input_parameters=init_parameters, atlas=atlas)
564
+ with self.telemetry_span("Fit atlas and continuum"):
565
+ extra_kwargs = self.parameters.solar_vignette_wavecal_fit_kwargs
566
+ logger.info(f"Calling fitter with extra kwargs: {extra_kwargs}")
567
+ fit_result = fitter(
568
+ input_spectrum=representative_spectrum,
569
+ **extra_kwargs,
570
+ )
484
571
 
485
- return char_spec
572
+ return fit_result
486
573
 
487
- def refine_gain_shifts(self, char_spec: np.ndarray, beam: int, modstate: int) -> np.ndarray:
574
+ def compute_initial_vignette_estimation(
575
+ self, beam: int, representative_spectrum: np.ndarray, continuum: np.ndarray
576
+ ) -> np.ndarray:
488
577
  """
489
- Refine the spectral shifts when matching characteristic spectra to the rectified input spectra.
578
+ Compute the initial, 1D estimate of the spectral vignette signal.
490
579
 
491
- An important detail of this functino is that the goodness of fit metric is the final gain image (i.e., raw
492
- input with solar spectrum removed). We minimize the residuals in the gain image.
580
+ The continuum is first removed from the representative spectrum to make the first guess at a vignette-corrected
581
+ solar spectrum. Then this solar spectrum is divided from the 2D lamp-and-spectral-shift corrected solar gain data.
493
582
 
494
- Parameters
495
- ----------
496
- char_spec : np.ndarray
497
- Computed characteristic spectra
583
+ Because the continuum was fit for a single spectrum, the result is a 2D array that contains the *spatial*
584
+ variation in the vignetting signal.
585
+ """
586
+ first_vignette_array = (
587
+ self.geo_corrected_beam_data(beam=beam) / (representative_spectrum / continuum)[:, None]
588
+ )
589
+ return first_vignette_array
590
+
591
+ def initialize_starting_fit_parameters(
592
+ self,
593
+ representative_spectrum: np.ndarray,
594
+ dispersion: Quantity,
595
+ spectral_order: int,
596
+ doppler_velocity: Quantity,
597
+ atlas: Atlas,
598
+ ) -> WavelengthCalibrationParametersWithContinuum:
599
+ """
600
+ Construct a `WavelengthCalibrationParametersWithContinuum` object containing initial guesses, fit flags, and bounds.
498
601
 
499
- beam : int
500
- The beam number for this array
602
+ A rough estimate of the initial CRVAL value is made using `solar_wavelength_calibration.calculate_initial_crval_guess`.
603
+ """
604
+ num_wave = representative_spectrum.size
605
+ normalized_abscissa = np.linspace(-1, 1, num_wave)
606
+
607
+ logger.info("Computing input wavelength vector")
608
+ input_wavelength_vector = compute_input_wavelength_vector(
609
+ central_wavelength=self.constants.wavelength * u.nm,
610
+ dispersion=dispersion,
611
+ grating_constant=self.constants.grating_constant_inverse_mm,
612
+ order=spectral_order,
613
+ incident_light_angle=self.constants.incident_light_angle_deg,
614
+ num_spec_px=representative_spectrum.size,
615
+ )
616
+ wavelength_range = input_wavelength_vector.max() - input_wavelength_vector.min()
617
+
618
+ logger.info("Computing initial CRVAL guess")
619
+ crval_init = calculate_initial_crval_guess(
620
+ input_wavelength_vector=input_wavelength_vector,
621
+ input_spectrum=representative_spectrum,
622
+ atlas=atlas,
623
+ negative_limit=-wavelength_range / 2,
624
+ positive_limit=wavelength_range / 2,
625
+ num_steps=500,
626
+ normalization_percentile=self.parameters.wavecal_init_crval_guess_normalization_percentile,
627
+ )
501
628
 
502
- modstate : int
503
- The modulator state for this array
629
+ logger.info(f"{crval_init = !s}")
504
630
 
631
+ fit_flags = FitFlagsModel(
632
+ continuum_level=False, # Because we're fitting the continuum with a function
633
+ incident_light_angle=False,
634
+ straylight_fraction=True,
635
+ resolving_power=True,
636
+ opacity_factor=True,
637
+ )
638
+
639
+ incident_light_angle = self.constants.incident_light_angle_deg
640
+ grating_constant = self.constants.grating_constant_inverse_mm
641
+ resolving_power = self.parameters.wavecal_init_resolving_power
642
+ opacity_factor = self.parameters.wavecal_init_opacity_factor
643
+ straylight_faction = self.parameters.wavecal_init_straylight_fraction
644
+ relative_atlas_scaling = self.estimate_relative_continuum_level(
645
+ crval_init=crval_init,
646
+ wavelength_range=wavelength_range,
647
+ atlas=atlas,
648
+ representative_spectrum=representative_spectrum,
649
+ )
650
+ logger.info(f"0th order coefficient initial guess: {relative_atlas_scaling}")
651
+
652
+ wavelength_search_width = dispersion * self.parameters.solar_vignette_crval_bounds_px
653
+ bounds = BoundsModel(
654
+ crval=LengthBoundRange(
655
+ min=crval_init - wavelength_search_width, max=crval_init + wavelength_search_width
656
+ ),
657
+ dispersion=DispersionBoundRange(
658
+ min=dispersion * (1 - self.parameters.solar_vignette_dispersion_bounds_fraction),
659
+ max=dispersion * (1 + self.parameters.solar_vignette_dispersion_bounds_fraction),
660
+ ),
661
+ incident_light_angle=AngleBoundRange(
662
+ min=incident_light_angle - 1 * u.deg, max=incident_light_angle + 1 * u.deg
663
+ ),
664
+ resolving_power=UnitlessBoundRange(min=1e5, max=5e5),
665
+ opacity_factor=UnitlessBoundRange(min=1.0, max=10),
666
+ straylight_fraction=UnitlessBoundRange(min=0.0, max=0.8),
667
+ )
668
+
669
+ init_params = WavelengthCalibrationParametersWithContinuum(
670
+ crval=crval_init,
671
+ dispersion=dispersion,
672
+ incident_light_angle=incident_light_angle,
673
+ grating_constant=grating_constant,
674
+ doppler_velocity=doppler_velocity,
675
+ order=spectral_order,
676
+ continuum_level=1,
677
+ resolving_power=resolving_power,
678
+ opacity_factor=opacity_factor,
679
+ straylight_fraction=straylight_faction,
680
+ fit_flags=fit_flags,
681
+ bounds=bounds,
682
+ continuum_poly_fit_order=self.parameters.solar_vignette_initial_continuum_poly_fit_order,
683
+ normalized_abscissa=normalized_abscissa,
684
+ zeroth_order_continuum_coefficient=relative_atlas_scaling,
685
+ )
686
+
687
+ return init_params
688
+
689
+ def estimate_relative_continuum_level(
690
+ self,
691
+ *,
692
+ crval_init: Quantity,
693
+ wavelength_range: Quantity,
694
+ atlas: Atlas,
695
+ representative_spectrum: np.ndarray,
696
+ ) -> float:
697
+ """
698
+ Estimate the multiplicative scaling between the representative spectrum and atlas solar transmission.
699
+
700
+ This scaling is used to set the initial guess of 0th-order polynomial fit coefficient. We estimate the scaling
701
+ factor by comparing the values of the two spectra at a given percent of the CDF. This percent is taken from
702
+ the `~dkist_processing_visp.models.parameters.VispParameters.wavecal_init_crval_guess_normalization_percentile`
703
+ pipeline parameter.
704
+ """
705
+ wave_min = crval_init - wavelength_range / 2
706
+ wave_max = crval_init + wavelength_range / 2
707
+
708
+ atlas_idx = np.where(
709
+ (atlas.solar_atlas_wavelength >= wave_min) & (atlas.solar_atlas_wavelength <= wave_max)
710
+ )
711
+ atlas_norm = np.nanpercentile(
712
+ atlas.solar_atlas_transmission[atlas_idx],
713
+ self.parameters.wavecal_init_crval_guess_normalization_percentile,
714
+ )
715
+ spec_norm = np.nanpercentile(
716
+ representative_spectrum,
717
+ self.parameters.wavecal_init_crval_guess_normalization_percentile,
718
+ )
719
+
720
+ return spec_norm / atlas_norm
721
+
722
+ def compute_final_vignette_estimate(self, init_vignette_correction: np.ndarray) -> np.ndarray:
723
+ """
724
+ Fit the spectral shape of continuum residuals for each spatial pixel.
725
+
726
+ The vignette estimation produced by `compute_initial_vignette_estimation` used a single continuum function for
727
+ the entire slit. This method fits the continuum for all pixels along the slit to build up a fully 2D estimate
728
+ of the vignette signal.
729
+
730
+ Each spatial pixel is fit separately with a polynomial via a RANSAC estimator.
731
+ """
732
+ model = self.build_RANSAC_model()
733
+ num_spectral, num_spatial = init_vignette_correction.shape
734
+ abscissa = np.arange(num_spectral)[
735
+ :, None
736
+ ] # Add extra dimension because sklearn requires it
737
+
738
+ logger.info(f"Fitting spectral vignetting for {num_spatial} spatial pixels")
739
+ final_vignette = np.zeros_like(init_vignette_correction)
740
+ for spatial_px in range(num_spatial):
741
+ finite_idx = np.isfinite(init_vignette_correction[:, spatial_px])
742
+ model.fit(abscissa[finite_idx, :], init_vignette_correction[finite_idx, spatial_px])
743
+ final_vignette[:, spatial_px] = model.predict(abscissa)
744
+
745
+ return final_vignette
746
+
747
+ def build_RANSAC_model(self) -> Pipeline:
748
+ """
749
+ Build a scikit-learn pipeline from a set of estimators.
750
+
751
+ Namely, construct a `~sklearn.pipeline.Pipeline` built from
752
+
753
+ `~sklearn.preprocessing.PolynomialFeatures` -> `~sklearn.preprocessing.RobustScaler` -> `~sklearn.linear_model.RANSACRegressor`
754
+ """
755
+ fit_order = self.parameters.solar_vignette_spectral_poly_fit_order
756
+ min_samples = self.parameters.solar_vignette_min_samples
757
+ logger.info(f"Building RANSAC model with {fit_order = } and {min_samples = }")
758
+
759
+ # PolynomialFeatures casts the pipeline as a polynomial fit
760
+ # see https://scikit-learn.org/stable/modules/generated/sklearn.preprocessing.PolynomialFeatures.html#sklearn.preprocessing.PolynomialFeatures
761
+ poly_feature = PolynomialFeatures(degree=fit_order)
762
+
763
+ # RobustScaler is a scale factor that is robust to outliers
764
+ # see https://scikit-learn.org/stable/modules/generated/sklearn.preprocessing.RobustScaler.html#sklearn.preprocessing.RobustScaler
765
+ scaler = RobustScaler()
766
+
767
+ # The RANSAC regressor iteratively sub-samples the input data and constructs a model with this sub-sample.
768
+ # The method used allows it to be robust to outliers.
769
+ # see https://scikit-learn.org/stable/modules/linear_model.html#ransac-regression
770
+ RANSAC = RANSACRegressor(min_samples=min_samples)
771
+
772
+ return make_pipeline(poly_feature, scaler, RANSAC)
773
+
774
+ def compute_characteristic_spectra(self, vignette_corrected_data: np.ndarray) -> np.ndarray:
775
+ """
776
+ Compute the 2D characteristic spectra via a median smooth in the spatial dimension.
777
+
778
+ A 2D characteristic spectra is needed because the line shape varys along the slit to the degree that a
779
+ single, 1D characteristic spectrum will not fully remove the solar lines for all positions in the final gain.
780
+
781
+ In this step we also normalize each spatial position by its continuum value. This removes low-order gradients in
782
+ the spatial direction that are known to be caused by imperfect illumination of the Lamp gains (which were used
783
+ to correct the data that will become the characteristic spectra).
784
+
785
+ Parameters
786
+ ----------
787
+ beam
788
+ The beam number for this array
505
789
 
506
790
  Returns
507
791
  -------
508
- np.ndarray
509
- Characteristic spectra array with refined spectral shifts
510
- """
511
- # Grab rectified input spectra that will be the shift target
512
- target_spectra = self.lamp_corrected_modstate_data(beam=beam, modstate=modstate)
513
- num_spec = target_spectra.shape[1]
514
-
515
- logger.info(f"Computing line zones for {beam=} and {modstate=}")
516
- zone_kwargs = {
517
- "prominence": self.parameters.solar_zone_prominence,
518
- "width": self.parameters.solar_zone_width,
519
- "bg_order": self.parameters.solar_zone_bg_order,
520
- "normalization_percentile": self.parameters.solar_zone_normalization_percentile,
521
- "rel_height": self.parameters.solar_zone_rel_height,
522
- }
523
- zones = self.compute_line_zones(char_spec, **zone_kwargs)
524
- logger.info(f"Found {zones=} for {beam=} and {modstate=}")
525
- if len(zones) == 0:
526
- raise ValueError(f"No zones found for {beam=} and {modstate=}")
792
+ char_spec
793
+ 2D characteristic spectra
794
+ """
795
+ spatial_median_window = self.parameters.solar_spatial_median_filter_width_px
796
+ normalization_percentile = (
797
+ self.parameters.solar_characteristic_spatial_normalization_percentile
798
+ )
799
+ logger.info(
800
+ f"Computing characteristic spectra for {spatial_median_window = } and {normalization_percentile = }"
801
+ )
802
+
803
+ masked_spectra = self.corrections_mask_hairlines(vignette_corrected_data)
804
+ # Normalize each spatial pixel by its own percentile. This removes large spatial gradients that are not solar
805
+ # signal.
806
+ normed_spectra = masked_spectra / np.nanpercentile(
807
+ masked_spectra, normalization_percentile, axis=0
808
+ )
809
+
810
+ # size = (1, window) means don't smooth in the spectral dimension
811
+ char_spec = spnd.median_filter(normed_spectra, size=(1, spatial_median_window))
527
812
 
528
- reshift_char_spec = np.zeros(char_spec.shape)
529
- logger.info(f"Refining shifts for {beam=} and {modstate=}")
530
- for i in range(num_spec):
531
- ref_spec = target_spectra[:, i] / np.nanmedian(target_spectra[:, i])
532
- spec = char_spec[:, i] / np.nanmedian(char_spec[:, i])
533
- shift = SolarCalibration.refine_shift(spec, ref_spec, zones=zones, x_init=0.0)
534
- reshift_char_spec[:, i] = spnd.shift(char_spec[:, i], shift, mode="reflect")
813
+ normed_char_spec = char_spec / np.nanpercentile(char_spec, normalization_percentile, axis=0)
535
814
 
536
- return reshift_char_spec
815
+ return normed_char_spec
537
816
 
538
817
  def distort_characteristic_spectra(
539
- self, char_spec: np.ndarray, beam: int, modstate: int
818
+ self,
819
+ char_spec: np.ndarray,
820
+ beam: int,
540
821
  ) -> np.ndarray:
541
822
  """
542
823
  Re-apply angle and state offset distortions to the characteristic spectra.
543
824
 
544
825
  Parameters
545
826
  ----------
546
- char_spec : np.ndarray
827
+ char_spec
547
828
  Computed characteristic spectra
548
829
 
549
- beam : int
830
+ beam
550
831
  The beam number for this array
551
832
 
552
- modstate : int
833
+ modstate
553
834
  The modulator state for this array
554
835
 
555
836
 
556
837
  Returns
557
838
  -------
558
- np.ndarray
839
+ distorted characteristic array
559
840
  Characteristic spectra array with angle and offset distortions re-applied
560
841
  """
561
- logger.info(f"Re-distorting characteristic spectra for {beam=} and {modstate=}")
842
+ logger.info(f"Re-distorting characteristic spectra for {beam=}")
562
843
  angle_array = next(
563
844
  self.read(
564
845
  tags=[VispTag.intermediate_frame(beam=beam), VispTag.task_geometric_angle()],
@@ -566,15 +847,9 @@ class SolarCalibration(
566
847
  )
567
848
  )
568
849
  angle = angle_array[0]
569
- state_offset = next(
570
- self.read(
571
- tags=[
572
- VispTag.intermediate_frame(beam=beam, modstate=modstate),
573
- VispTag.task_geometric_offset(),
574
- ],
575
- decoder=fits_array_decoder,
576
- )
577
- )
850
+
851
+ # See comment in `do_initial_corrections`; we don't care about the state_offset when making the solar gain
852
+ state_offset = np.array([0.0, 0.0])
578
853
 
579
854
  distorted_spec = next(
580
855
  self.corrections_correct_geometry(char_spec, -1 * state_offset, -1 * angle)
@@ -582,164 +857,329 @@ class SolarCalibration(
582
857
 
583
858
  return distorted_spec
584
859
 
585
- def equalize_modstates(self, array_dict: dict[int, np.ndarray]) -> dict[int, np.ndarray]:
586
- """Adjust the flux of all modstates for a single beam so they all have the same median.
587
-
588
- That median is the global median over all modstates.
589
- """
590
- global_median_array = np.nanmedian(list(array_dict.values()), axis=0)
591
- output_dict = dict()
592
-
593
- for modstate, array in array_dict.items():
594
- correction = np.nanmedian(global_median_array / array)
595
- logger.info(f"Equalization correction for {modstate = } is {correction:.6f}")
596
- final_gain = array * correction
597
- output_dict[modstate] = final_gain
598
-
599
- return output_dict
600
-
601
860
  def remove_solar_signal(
602
- self, char_solar_spectra: np.ndarray, beam: int, modstate: int
861
+ self,
862
+ char_solar_spectra: np.ndarray,
863
+ beam: int,
603
864
  ) -> np.ndarray:
604
865
  """
605
866
  Remove the distorted characteristic solar spectra from the original spectra.
606
867
 
607
868
  Parameters
608
869
  ----------
609
- char_solar_spectra : np.ndarray
870
+ char_solar_spectra
610
871
  Characteristic solar spectra
611
872
 
612
- beam : int
873
+ beam
613
874
  The beam number for this array
614
875
 
615
- modstate : int
616
- The modulator state for this array
617
-
618
-
619
876
  Returns
620
877
  -------
621
- np.ndarray
878
+ final gain
622
879
  Original spectral array with characteristic solar spectra removed
623
-
624
880
  """
625
- logger.info(f"Removing characteristic solar spectra from {beam=} and {modstate=}")
626
- input_gain = self.bg_corrected_modstate_data(beam=beam, modstate=modstate)
881
+ logger.info(f"Removing characteristic solar spectra from {beam=}")
882
+ input_gain = self.bg_corrected_beam_data(beam=beam)
627
883
 
628
884
  final_gain = input_gain / char_solar_spectra
629
885
 
630
886
  return final_gain
631
887
 
632
888
  def write_solar_gain_calibration(
633
- self, gain_array: np.ndarray, beam: int, modstate: int
889
+ self,
890
+ gain_array: np.ndarray,
891
+ beam: int,
634
892
  ) -> None:
635
893
  """
636
- Write a solar gain array for a single beam and modstate.
894
+ Write a solar gain array for a single beam.
637
895
 
638
896
  Parameters
639
897
  ----------
640
- gain_array: np.ndarray
898
+ gain_array
641
899
  Solar gain array
642
900
 
643
- beam : int
901
+ beam
644
902
  The beam number for this array
645
-
646
- modstate : int
647
- The modulator state for this array
648
-
649
-
650
- Returns
651
- -------
652
- None
653
903
  """
654
- logger.info(f"Writing final SolarGain for {beam=} and {modstate=}")
904
+ logger.info(f"Writing final SolarGain for {beam = }")
655
905
  self.write(
656
906
  data=gain_array,
657
907
  tags=[
658
- VispTag.intermediate_frame(beam=beam, modstate=modstate),
908
+ VispTag.intermediate_frame(beam=beam),
659
909
  VispTag.task_solar_gain(),
660
910
  ],
661
911
  encoder=fits_array_encoder,
662
912
  )
663
913
 
664
- @staticmethod
665
- def refine_shift(
666
- spec: np.ndarray, target_spec: np.ndarray, zones: list[tuple[int, int]], x_init: float
667
- ) -> float:
914
+ def record_vignette_quality_metrics(
915
+ self,
916
+ beam: int,
917
+ representative_spectrum: np.ndarray,
918
+ fit_result: FitResult,
919
+ vignette_corrected_gain: np.ndarray,
920
+ ) -> None:
668
921
  """
669
- Refine the shift for a single spatial position back to the rectified input spectra.
922
+ Save vignette fit results to disk for later quality metric building.
670
923
 
671
- Line zones are used to increase the SNR of the chisq and the final shift is the mean of the shifts computed
672
- for each zone.
924
+ We save the atlas fit of the initial vignette estimation and the global solar spectrum derived from the
925
+ vignette-corrected 2D gain images.
926
+ """
927
+ logger.info(f"Recording initial vignette quality data for {beam = }")
928
+ first_vignette_quality_outputs = {
929
+ "output_wave_vec": fit_result.best_fit_wavelength_vector,
930
+ "input_spectrum": representative_spectrum,
931
+ "best_fit_atlas": fit_result.best_fit_atlas,
932
+ "best_fit_continuum": fit_result.best_fit_continuum,
933
+ "residuals": fit_result.minimizer_result.residual,
934
+ }
935
+ self.write(
936
+ data=first_vignette_quality_outputs,
937
+ tags=[VispTag.quality(VispMetricCode.solar_first_vignette), VispTag.beam(beam)],
938
+ encoder=asdf_encoder,
939
+ )
673
940
 
674
- Parameters
675
- ----------
676
- spec : np.ndarray
677
- The 1D spectrum to shift back
941
+ logger.info(f"Recording final vignette-correced gain quality data for {beam = }")
942
+ global_median = np.nanmedian(vignette_corrected_gain, axis=1)
943
+ low_deviation = np.nanpercentile(vignette_corrected_gain, 5, axis=1)
944
+ high_deviation = np.nanpercentile(vignette_corrected_gain, 95, axis=1)
945
+ final_correction_quality_outputs = {
946
+ "output_wave_vec": fit_result.best_fit_wavelength_vector,
947
+ "median_spec": global_median,
948
+ "low_deviation": low_deviation,
949
+ "high_deviation": high_deviation,
950
+ }
951
+ self.write(
952
+ data=final_correction_quality_outputs,
953
+ tags=[VispTag.quality(VispMetricCode.solar_final_vignette), VispTag.beam(beam)],
954
+ encoder=asdf_encoder,
955
+ )
678
956
 
679
- target_spec : np.ndarray
680
- The reference spectrum. This should be the un-shifted, raw spectrum at the same position as `spec`
957
+ def _log_wavecal_parameters(
958
+ self, dispersion: Quantity, order: int, doppler_velocity: Quantity
959
+ ) -> None:
960
+ """Log initial guess and instrument-derived wavecal parameters."""
961
+ logger.info(f"central wavelength = {self.constants.wavelength * u.nm !s}")
962
+ logger.info(f"{dispersion = !s}")
963
+ logger.info(f"{order = }")
964
+ logger.info(f"grating constant = {self.constants.grating_constant_inverse_mm !s}")
965
+ logger.info(f"incident light angle = {self.constants.incident_light_angle_deg !s}")
966
+ logger.info(f"reflected light angle = {self.constants.reflected_light_angle_deg !s}")
967
+ logger.info(f"{doppler_velocity = !s}")
968
+ logger.info(f"pixel pitch = {self.parameters.wavecal_pixel_pitch_micron_per_pix !s}")
969
+ logger.info(f"solar ip start time = {self.constants.solar_gain_ip_start_time}")
970
+
971
+
972
+ def polynomial_continuum_model(
973
+ wavelength_vector: np.ndarray, fit_parameters: Parameters, fit_order: int, abscissa: np.ndarray
974
+ ) -> np.ndarray:
975
+ """
976
+ Parameterize the continuum as a polynomial.
681
977
 
682
- zones : List
683
- List of zone borders (in px coords)
978
+ Polynomial coefficients are taken from the input ``fit_parameters`` object. The ``wavelength_vector`` argument is not
979
+ used by required to conform to the signature expected by `WavelengthCalibrationFitter`.
684
980
 
685
- x_init: float
686
- Initial guess for the shift. This is used to shift the zones so it needs to be pretty good, but not perfect.
981
+ Parameters
982
+ ----------
983
+ wavelength_vector
984
+ Unused, but required by `WavelengthCalibrationFitter`
687
985
 
688
- Returns
689
- -------
690
- float
691
- The shift value
692
- """
693
- shifts = np.zeros(len(zones))
694
- for i, z in enumerate(zones):
695
- if z[1] + int(x_init) >= spec.size:
696
- logger.info(f"Ignoring zone {z} with init {x_init} because it's out of range")
697
- continue
698
- idx = np.arange(z[0], z[1]) + int(x_init)
699
- shift = spo.minimize(
700
- SolarCalibration.shift_func,
701
- np.array([x_init]),
702
- args=(target_spec, spec, idx),
703
- method="nelder-mead",
704
- ).x[0]
705
- shifts[i] = shift
706
-
707
- return np.nanmedian(shifts)
708
-
709
- @staticmethod
710
- def shift_func(
711
- par: list[float], ref_spec: np.ndarray, spec: np.ndarray, idx: np.ndarray
712
- ) -> float:
713
- """
714
- Non-chisq based goodness of fit calculator for computing spectral shifts.
986
+ fit_parameters
987
+ Object containing the current fit parameters
715
988
 
716
- Instead of chisq, the metric approximates the final Gain image.
989
+ fit_order
990
+ Polynomial order. This needs to match the number of polynomial coefficients in the ``fit_parameters`` argument.
991
+ Specifically, this method expects parameters called `[f"poly_coeff_{i:02n}" for i in range(fit_order + 1)]`.
717
992
 
718
- Parameters
719
- ----------
720
- par : List
721
- List of parameters for minimization
993
+ abscissa
994
+ Array of values used to compute the continuum. For the fit to be independent of wavelength these values should
995
+ be in the range [-1, 1].
996
+ """
997
+ coeffs = [fit_parameters[f"poly_coeff_{i:02n}"].value for i in range(fit_order + 1)]
998
+ return np.polyval(coeffs, abscissa)
722
999
 
723
- ref_spec : np.ndarray
724
- Reference spectra
725
1000
 
726
- spec : np.ndarray
727
- Data
1001
+ ##################################################################################
1002
+ # TODO: The following definitions should go in the wavecal module once it exists #
1003
+ ##################################################################################
728
1004
 
729
- idx : np.ndarray
730
- Range of wavelength pixels that will be compared in fit
731
1005
 
732
- Returns
733
- -------
734
- float
735
- Goodness of fit metric
736
-
737
- """
738
- shift = par[0]
739
- shifted_spec = spnd.shift(spec, shift, mode="constant", cval=np.nan)
740
- final_gain = (ref_spec / shifted_spec)[idx]
741
- slope = (final_gain[-1] - final_gain[0]) / final_gain.size
742
- bg = slope * np.arange(final_gain.size) + final_gain[0]
743
- subbed_gain = np.abs((final_gain) - bg)
744
- fit_metric = np.nansum(subbed_gain[np.isfinite(subbed_gain)])
745
- return fit_metric
1006
+ def compute_order(
1007
+ central_wavelength: Quantity,
1008
+ incident_light_angle: Quantity,
1009
+ reflected_light_angle: Quantity,
1010
+ grating_constant: Quantity,
1011
+ ) -> int:
1012
+ r"""
1013
+ Compute the spectral order from the spectrograph setup.
1014
+
1015
+ From the grating equation, the spectral order, :math:`m`:, is
1016
+
1017
+ .. math::
1018
+ m = \frac{\sin\alpha + \sin\beta}{G \lambda}
1019
+
1020
+ where :math:`\alpha` and :math:`\beta` are the incident and reflected light angles, respectively, :math:`G` is the
1021
+ grating constant (lines per mm), and :math:`\lambda` is the central wavelength. All of these values come from the
1022
+ input headers.
1023
+
1024
+ Parameters
1025
+ ----------
1026
+ central_wavelength
1027
+ Wavelength of the center of the spectral window.
1028
+
1029
+ incident_light_angle
1030
+ Angle of light incident to the spectrograph grating. Often called :math:`\alpha`.
1031
+
1032
+ reflected_light_angle
1033
+ Angle of light reflected from spectrograph grating. Often called :math:`\beta`.
1034
+
1035
+ grating_constant
1036
+ Grating constant of the spectrograph grating [lines per mm]
1037
+
1038
+ Returns
1039
+ -------
1040
+ spectral_order
1041
+ The order of the given spectrograph configuration
1042
+ """
1043
+ return int(
1044
+ (np.sin(incident_light_angle) + np.sin(reflected_light_angle))
1045
+ / (grating_constant * central_wavelength)
1046
+ )
1047
+
1048
+
1049
+ def compute_initial_dispersion(
1050
+ central_wavelength: Quantity,
1051
+ incident_light_angle: Quantity,
1052
+ reflected_light_angle: Quantity,
1053
+ lens_parameters: list[Quantity],
1054
+ pixel_pitch: Quantity,
1055
+ ) -> Quantity:
1056
+ r"""
1057
+ Compute the dispersion (:math:`d\,\lambda/d\, px`) given the spectrograph setup.
1058
+
1059
+ The dispersion is given via
1060
+
1061
+ .. math::
1062
+ d\,\lambda / d\, px = \frac{p \lambda_0 \cos\beta}{f (\sin\alpha + \sin\beta)}
1063
+
1064
+ where :math:`p` is the pixel pitch (microns per pix), :math:`\lambda_0` is the central wavelength, :math:`f` is the
1065
+ camera focal length, and :math:`\alpha` and :math:`\beta` are the incident and reflected light angles, respectively.
1066
+ :math:`\lambda_0`, :math:`\alpha`, and :math:`\beta` are taken from input headers, while :math:`f` and :math:`p` are
1067
+ pipeline parameters.
1068
+
1069
+ Parameters
1070
+ ----------
1071
+ central_wavelength
1072
+ Wavelength of the center of the spectral window.
1073
+
1074
+ incident_light_angle
1075
+ Angle of light incident to the spectrograph grating. Often called :math:`\alpha`.
1076
+
1077
+ reflected_light_angle
1078
+ Angle of light reflected from spectrograph grating. Often called :math:`\beta`.
1079
+
1080
+ lens_parameters
1081
+ Parameterization of lense focal length as zero, first, and second orders of wavelength. If the total focal
1082
+ 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]`.
1083
+
1084
+ pixel_pitch
1085
+ The physical size of a single pixel
1086
+
1087
+ Returns
1088
+ -------
1089
+ dispersion
1090
+ The computed dispersion in units of nm / px
1091
+ """
1092
+ camera_focal_length = lens_parameters[0] + central_wavelength * (
1093
+ lens_parameters[1] + central_wavelength * lens_parameters[2]
1094
+ )
1095
+ logger.info(f"{camera_focal_length = !s}")
1096
+
1097
+ linear_dispersion = (
1098
+ camera_focal_length
1099
+ * (np.sin(incident_light_angle) + np.sin(reflected_light_angle))
1100
+ / (np.cos(reflected_light_angle) * central_wavelength)
1101
+ )
1102
+
1103
+ dispersion = pixel_pitch / linear_dispersion
1104
+
1105
+ return dispersion.to(u.nm / u.pix)
1106
+
1107
+
1108
+ def compute_doppler_velocity(time_of_observation: str) -> Quantity:
1109
+ """Find the speed at which DKIST is moving relative to the Sun's center.
1110
+
1111
+ Positive values refer to when DKIST is moving away from the sun.
1112
+
1113
+ Parameters
1114
+ ----------
1115
+ time_of_observation
1116
+ Time at which to compute the relative Dopper velocity. Any string that can be parsed by `astropy.time.Time` is
1117
+ acceptable.
1118
+
1119
+ Returns
1120
+ -------
1121
+ doppler_velocity
1122
+ The relative velocity between observer and the Sun with units of km / s
1123
+ """
1124
+ coord = location_of_dkist.get_gcrs(obstime=Time(time_of_observation))
1125
+ heliocentric_coord = coord.transform_to(HeliocentricInertial(obstime=Time(time_of_observation)))
1126
+ obs_vr_kms = heliocentric_coord.d_distance
1127
+ return obs_vr_kms
1128
+
1129
+
1130
+ def compute_input_wavelength_vector(
1131
+ *,
1132
+ central_wavelength: Quantity,
1133
+ dispersion: Quantity,
1134
+ grating_constant: Quantity,
1135
+ order: int,
1136
+ incident_light_angle: Quantity,
1137
+ num_spec_px: int,
1138
+ ) -> u.Quantity:
1139
+ r"""
1140
+ Compute a wavelength vector based on information about the spectrograph setup.
1141
+
1142
+ The parameterization of the grating equation is via `astropy.wcs.WCS`, which follows section 5 of
1143
+ `Greisen et al (2006) <https://ui.adsabs.harvard.edu/abs/2006A%26A...446..747G/abstract>`_.
1144
+
1145
+ Parameters
1146
+ ----------
1147
+ central_wavelength
1148
+ Wavelength at the center of the spectral window. This function forces the value of the output vector to be
1149
+ ``central_wavelength`` at index ``num_spec // 2 + 1``.
1150
+
1151
+ dispersion
1152
+ Spectrograph dispersion [nm / px]
1153
+
1154
+ grating_constant
1155
+ Grating constant of the spectrograph grating [lines per mm]
1156
+
1157
+ order
1158
+ Spectrograph order
1159
+
1160
+ incident_light_angle
1161
+ Angle of light incident to the spectrograph grating. Often called :math:`\alpha`.
1162
+
1163
+ num_spec_px
1164
+ The length of the output wavelength vector. Defines size and physical limits of the output.
1165
+
1166
+ Returns
1167
+ -------
1168
+ wave_vec
1169
+ 1D array of length ``num_spec`` containing the wavelength values described by the input WCS parameterization.
1170
+ The units of this array will be nanometers.
1171
+ """
1172
+ wavelength_parameters = WavelengthParameters(
1173
+ crpix=num_spec_px // 2 + 1,
1174
+ crval=central_wavelength.to_value(u.nm),
1175
+ dispersion=dispersion.to_value(u.nm / u.pix),
1176
+ grating_constant=grating_constant.to_value(1 / u.mm),
1177
+ order=order,
1178
+ incident_light_angle=incident_light_angle.to_value(u.deg),
1179
+ cunit="nm",
1180
+ )
1181
+ header = wavelength_parameters.to_header(axis_num=1)
1182
+ wcs = WCS(header)
1183
+ input_wavelength_vector = wcs.spectral.pixel_to_world(np.arange(num_spec_px)).to(u.nm)
1184
+
1185
+ return input_wavelength_vector