dkist-processing-cryonirsp 1.9.0__py3-none-any.whl → 1.10.0rc1__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 (28) hide show
  1. changelog/167.feature.rst +1 -0
  2. dkist_processing_cryonirsp/config.py +4 -1
  3. dkist_processing_cryonirsp/models/constants.py +14 -1
  4. dkist_processing_cryonirsp/models/parameters.py +11 -19
  5. dkist_processing_cryonirsp/parsers/cryonirsp_l0_fits_access.py +3 -2
  6. dkist_processing_cryonirsp/tasks/__init__.py +1 -1
  7. dkist_processing_cryonirsp/tasks/parse.py +15 -3
  8. dkist_processing_cryonirsp/tasks/sp_wavelength_calibration.py +300 -0
  9. dkist_processing_cryonirsp/tasks/write_l1.py +6 -16
  10. dkist_processing_cryonirsp/tests/conftest.py +13 -20
  11. dkist_processing_cryonirsp/tests/header_models.py +12 -0
  12. dkist_processing_cryonirsp/tests/local_trial_workflows/l0_cals_only.py +17 -17
  13. dkist_processing_cryonirsp/tests/local_trial_workflows/l0_to_l1.py +17 -19
  14. dkist_processing_cryonirsp/tests/local_trial_workflows/local_trial_helpers.py +3 -3
  15. dkist_processing_cryonirsp/tests/test_cryo_constants.py +4 -1
  16. dkist_processing_cryonirsp/tests/test_parameters.py +4 -0
  17. dkist_processing_cryonirsp/tests/test_parse.py +28 -2
  18. dkist_processing_cryonirsp/tests/{test_sp_dispersion_axis_correction.py → test_sp_wavelength_calibration.py} +6 -23
  19. dkist_processing_cryonirsp/tests/test_write_l1.py +15 -15
  20. dkist_processing_cryonirsp/workflows/sp_l0_processing.py +3 -3
  21. dkist_processing_cryonirsp/workflows/trial_workflows.py +3 -3
  22. {dkist_processing_cryonirsp-1.9.0.dist-info → dkist_processing_cryonirsp-1.10.0rc1.dist-info}/METADATA +14 -13
  23. {dkist_processing_cryonirsp-1.9.0.dist-info → dkist_processing_cryonirsp-1.10.0rc1.dist-info}/RECORD +27 -25
  24. docs/index.rst +1 -0
  25. docs/wavelength_calibration.rst +62 -0
  26. dkist_processing_cryonirsp/tasks/sp_dispersion_axis_correction.py +0 -465
  27. {dkist_processing_cryonirsp-1.9.0.dist-info → dkist_processing_cryonirsp-1.10.0rc1.dist-info}/WHEEL +0 -0
  28. {dkist_processing_cryonirsp-1.9.0.dist-info → dkist_processing_cryonirsp-1.10.0rc1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1 @@
1
+ This change pulls the dispersion axis correction out of the Cryonirsp pipeline and uses the generalized solar-wavelength-calibration library.
@@ -1,11 +1,14 @@
1
1
  """Configuration for the dkist-processing-cryonirsp package and the logging thereof."""
2
2
  from dkist_processing_common.config import DKISTProcessingCommonConfiguration
3
+ from pydantic import Field
3
4
 
4
5
 
5
6
  class DKISTProcessingCryoNIRSPConfigurations(DKISTProcessingCommonConfiguration):
6
7
  """Configurations custom to the dkist-processing-cryonirsp package."""
7
8
 
8
- pass # nothing custom yet
9
+ fts_atlas_data_dir: str | None = Field(
10
+ default=None, description="Common cached directory for a downloaded FTS Atlas."
11
+ )
9
12
 
10
13
 
11
14
  dkist_processing_cryonirsp_configurations = DKISTProcessingCryoNIRSPConfigurations()
@@ -2,6 +2,7 @@
2
2
  from enum import Enum
3
3
  from enum import unique
4
4
 
5
+ import astropy.units as u
5
6
  from dkist_processing_common.models.constants import BudName
6
7
  from dkist_processing_common.models.constants import ConstantsBase
7
8
 
@@ -54,6 +55,8 @@ class CryonirspBudName(Enum):
54
55
  gain_frame_type_list = "GAIN_FRAME_TYPE_LIST"
55
56
  lamp_gain_frame_type_list = "LAMP_GAIN_FRAME_TYPE_LIST"
56
57
  solar_gain_frame_type_list = "SOLAR_GAIN_FRAME_TYPE_LIST"
58
+ center_wavelength = "CENTER_WAVELENGTH"
59
+ slit_width = "SLIT_WIDTH"
57
60
 
58
61
 
59
62
  class CryonirspConstants(ConstantsBase):
@@ -112,10 +115,20 @@ class CryonirspConstants(ConstantsBase):
112
115
  """Grating littrow angle (deg)."""
113
116
  return self._db_dict[CryonirspBudName.grating_littrow_angle_deg.value]
114
117
 
118
+ @property
119
+ def center_wavelength(self) -> float:
120
+ """Center wavelength of the selected filter (nm)."""
121
+ return self._db_dict[CryonirspBudName.center_wavelength.value]
122
+
123
+ @property
124
+ def slit_width(self) -> float:
125
+ """Physical width of the selected slit (um)."""
126
+ return self._db_dict[CryonirspBudName.slit_width.value]
127
+
115
128
  @property
116
129
  def grating_constant(self) -> float:
117
130
  """Grating constant."""
118
- return self._db_dict[CryonirspBudName.grating_constant.value]
131
+ return self._db_dict[CryonirspBudName.grating_constant.value] / u.mm
119
132
 
120
133
  @property
121
134
  def camera_readout_mode(self) -> str:
@@ -7,6 +7,7 @@ import numpy as np
7
7
  from dkist_processing_common.models.parameters import ParameterArmIdMixin
8
8
  from dkist_processing_common.models.parameters import ParameterBase
9
9
  from dkist_processing_common.models.parameters import ParameterWavelengthMixin
10
+ from solar_wavelength_calibration import DownloadConfig
10
11
 
11
12
  from dkist_processing_cryonirsp.models.exposure_conditions import AllowableOpticalDensityFilterNames
12
13
 
@@ -267,24 +268,15 @@ class CryonirspParameters(ParameterBase, ParameterWavelengthMixin, ParameterArmI
267
268
  """Return the CryoNIRSP pixel pitch."""
268
269
  return self._find_most_recent_past_value("cryonirsp_pixel_pitch_micron") * u.micron
269
270
 
270
- @cached_property
271
- def solar_atlas(self) -> np.ndarray:
272
- """Solar reference atlas.
273
-
274
- Contains two arrays:
275
- - wavelength in nanometers
276
- - transmission at given wavelength
277
- """
278
- param_obj = self._find_most_recent_past_value("cryonirsp_solar_atlas")
279
- return self._load_param_value_from_numpy_save(param_obj=param_obj)
271
+ @property
272
+ def wavecal_atlas_download_config(self) -> DownloadConfig:
273
+ """Define the `~solar_wavelength_calibration.DownloadConfig` used to grab the Solar atlas used for wavelength calibration."""
274
+ config_dict = self._find_most_recent_past_value("cryonirsp_wavecal_atlas_download_config")
275
+ return DownloadConfig.model_validate(config_dict)
280
276
 
281
277
  @cached_property
282
- def telluric_atlas(self) -> np.ndarray:
283
- """Telluric reference atlas.
284
-
285
- Contains two arrays:
286
- - wavelength in nanometers
287
- - transmission at given wavelength
288
- """
289
- param_obj = self._find_most_recent_past_value("cryonirsp_telluric_atlas")
290
- return self._load_param_value_from_numpy_save(param_obj=param_obj)
278
+ def wavecal_fraction_of_unweighted_edge_pixels(self) -> int:
279
+ """Return the fraction of edge pixels to weight to zero during the wavelength calibration."""
280
+ return self._find_most_recent_past_value(
281
+ "cryonirsp_wavecal_fraction_of_unweighted_edge_pixels"
282
+ )
@@ -97,8 +97,7 @@ class CryonirspL0FitsAccess(L0FitsAccess):
97
97
  )
98
98
  self.grating_position_deg: float = self.header["CNGRTPOS"]
99
99
  self.grating_littrow_angle_deg: float = self.header["CNGRTLAT"]
100
- # grating_constant is in the L0 header in (mm)^(-1) when needed in (m)^(-1) hence multiply by 1000.
101
- self.grating_constant: float = self.header["CNGRTCON"] * 1000
100
+ self.grating_constant: float = self.header["CNGRTCON"]
102
101
  self.obs_ip_start_time = self.header["DKIST011"]
103
102
  # The ExposureConditions are a combination of the exposure time and the OD filter name:
104
103
  self.exposure_conditions = ExposureConditions(
@@ -106,6 +105,8 @@ class CryonirspL0FitsAccess(L0FitsAccess):
106
105
  self.header["CNFILTNP"].upper(),
107
106
  )
108
107
  self.solar_gain_ip_start_time = self.header["DATE-OBS"]
108
+ self.center_wavelength = self.header["CNCENWAV"]
109
+ self.slit_width = self.header["CNSLITW"]
109
110
 
110
111
  @property
111
112
  def cn1_scan_step(self):
@@ -11,8 +11,8 @@ from dkist_processing_cryonirsp.tasks.make_movie_frames import *
11
11
  from dkist_processing_cryonirsp.tasks.parse import *
12
12
  from dkist_processing_cryonirsp.tasks.quality_metrics import *
13
13
  from dkist_processing_cryonirsp.tasks.sp_beam_boundaries import *
14
- from dkist_processing_cryonirsp.tasks.sp_dispersion_axis_correction import *
15
14
  from dkist_processing_cryonirsp.tasks.sp_geometric import *
16
15
  from dkist_processing_cryonirsp.tasks.sp_science import *
17
16
  from dkist_processing_cryonirsp.tasks.sp_solar_gain import *
17
+ from dkist_processing_cryonirsp.tasks.sp_wavelength_calibration import *
18
18
  from dkist_processing_cryonirsp.tasks.write_l1 import *
@@ -247,21 +247,21 @@ class ParseL0CryonirspSPLinearizedData(ParseL0CryonirspLinearizedData):
247
247
  TaskNearFloatBud(
248
248
  constant_name=CryonirspBudName.grating_position_deg.value,
249
249
  metadata_key="grating_position_deg",
250
- ip_task_types=TaskName.solar_gain.value,
250
+ ip_task_types=[TaskName.observe.value, TaskName.solar_gain.value],
251
251
  task_type_parsing_function=parse_header_ip_task_with_gains,
252
252
  tolerance=0.01,
253
253
  ),
254
254
  TaskNearFloatBud(
255
255
  constant_name=CryonirspBudName.grating_littrow_angle_deg.value,
256
256
  metadata_key="grating_littrow_angle_deg",
257
- ip_task_types=TaskName.solar_gain.value,
257
+ ip_task_types=[TaskName.observe.value, TaskName.solar_gain.value],
258
258
  task_type_parsing_function=parse_header_ip_task_with_gains,
259
259
  tolerance=0.01,
260
260
  ),
261
261
  TaskUniqueBud(
262
262
  constant_name=CryonirspBudName.grating_constant.value,
263
263
  metadata_key="grating_constant",
264
- ip_task_types=TaskName.solar_gain.value,
264
+ ip_task_types=[TaskName.observe.value, TaskName.solar_gain.value],
265
265
  task_type_parsing_function=parse_header_ip_task_with_gains,
266
266
  ),
267
267
  TaskUniqueBud(
@@ -276,6 +276,18 @@ class ParseL0CryonirspSPLinearizedData(ParseL0CryonirspLinearizedData):
276
276
  ip_task_types=TaskName.solar_gain.value,
277
277
  task_type_parsing_function=parse_header_ip_task_with_gains,
278
278
  ),
279
+ TaskUniqueBud(
280
+ constant_name=CryonirspBudName.center_wavelength.value,
281
+ metadata_key="center_wavelength",
282
+ ip_task_types=[TaskName.observe.value, TaskName.solar_gain.value],
283
+ task_type_parsing_function=parse_header_ip_task_with_gains,
284
+ ),
285
+ TaskUniqueBud(
286
+ constant_name=CryonirspBudName.slit_width.value,
287
+ metadata_key="slit_width",
288
+ ip_task_types=[TaskName.observe.value, TaskName.solar_gain.value],
289
+ task_type_parsing_function=parse_header_ip_task_with_gains,
290
+ ),
279
291
  CheckLampGainFramesPickyBud(),
280
292
  CryonirspTaskExposureConditionsBud(
281
293
  stem_name=CryonirspBudName.lamp_gain_exposure_conditions_list.value,
@@ -0,0 +1,300 @@
1
+ """Cryo SP wavelength calibration task. See :doc:`this page </wavelength_calibration>` for more information."""
2
+ import math
3
+
4
+ import astropy.units as u
5
+ import numpy as np
6
+ from astropy.time import Time
7
+ from astropy.wcs import WCS
8
+ from dkist_processing_common.codecs.json import json_encoder
9
+ from dkist_processing_common.models.dkist_location import location_of_dkist
10
+ from dkist_service_configuration.logging import logger
11
+ from solar_wavelength_calibration import Atlas
12
+ from solar_wavelength_calibration import WavelengthCalibrationFitter
13
+ from solar_wavelength_calibration.fitter.parameters import AngleBoundRange
14
+ from solar_wavelength_calibration.fitter.parameters import BoundsModel
15
+ from solar_wavelength_calibration.fitter.parameters import DispersionBoundRange
16
+ from solar_wavelength_calibration.fitter.parameters import LengthBoundRange
17
+ from solar_wavelength_calibration.fitter.parameters import UnitlessBoundRange
18
+ from solar_wavelength_calibration.fitter.parameters import WavelengthCalibrationParameters
19
+ from solar_wavelength_calibration.fitter.wavelength_fitter import calculate_initial_crval_guess
20
+ from solar_wavelength_calibration.fitter.wavelength_fitter import WavelengthParameters
21
+ from sunpy.coordinates import HeliocentricInertial
22
+
23
+ from dkist_processing_cryonirsp.codecs.fits import cryo_fits_array_decoder
24
+ from dkist_processing_cryonirsp.models.tags import CryonirspTag
25
+ from dkist_processing_cryonirsp.tasks.cryonirsp_base import CryonirspTaskBase
26
+
27
+ __all__ = ["SPWavelengthCalibration"]
28
+
29
+
30
+ class SPWavelengthCalibration(CryonirspTaskBase):
31
+ """Task class for correcting the dispersion axis wavelength values.
32
+
33
+ Parameters
34
+ ----------
35
+ recipe_run_id : int
36
+ id of the recipe run used to identify the workflow run this task is part of
37
+ workflow_name : str
38
+ name of the workflow to which this instance of the task belongs
39
+ workflow_version : str
40
+ version of the workflow to which this instance of the task belongs
41
+
42
+ """
43
+
44
+ record_provenance = True
45
+
46
+ def run(self) -> None:
47
+ """
48
+ Run method for the task.
49
+
50
+ - Gather 1D characteristic spectrum. This will be the initial spectrum.
51
+ - Get a header from a solar gain frame for initial wavelength estimation.
52
+ - Compute the theoretical dispersion, order, and incident light angle.
53
+ - Compute the input wavelength vector from the spectrum and header.
54
+ - Get the Doppler velocity and resolving power.
55
+ - Define fitting bounds and initialize model parameters.
56
+ - Set and normalize spectral weights, zeroing edges.
57
+ - Fit the profile using WavelengthCalibrationFitter.
58
+ - Write fit results to disk.
59
+
60
+ Returns
61
+ -------
62
+ None
63
+ """
64
+ with self.apm_processing_step("Load input spectrum and wavelength"):
65
+ logger.info("Loading input spectrum")
66
+ input_spectrum = next(
67
+ self.read(
68
+ tags=[
69
+ CryonirspTag.intermediate_frame(beam=1),
70
+ CryonirspTag.task_characteristic_spectra(),
71
+ ],
72
+ decoder=cryo_fits_array_decoder,
73
+ )
74
+ )
75
+
76
+ logger.info(
77
+ "Computing instrument specific dispersion, order, and incident light angle."
78
+ )
79
+ (
80
+ dispersion,
81
+ order,
82
+ incident_light_angle,
83
+ ) = self.get_theoretical_dispersion_order_light_angle()
84
+
85
+ logger.info("Computing initial wavelength vector.")
86
+ input_wavelength_vector = self.compute_input_wavelength_vector(
87
+ spectrum=input_spectrum,
88
+ dispersion=dispersion,
89
+ order=order,
90
+ incident_light_angle=incident_light_angle,
91
+ )
92
+
93
+ # Get the doppler velocity
94
+ doppler_velocity = self.get_doppler_velocity()
95
+ logger.info(f"{doppler_velocity = !s}")
96
+
97
+ # Get the resolving power
98
+ resolving_power = self.get_resolving_power()
99
+ logger.info(f"{resolving_power = }")
100
+
101
+ with self.apm_processing_step("Compute brute-force CRVAL initial guess"):
102
+ atlas = Atlas(config=self.parameters.wavecal_atlas_download_config)
103
+ crval = calculate_initial_crval_guess(
104
+ input_wavelength_vector=input_wavelength_vector,
105
+ input_spectrum=input_spectrum,
106
+ atlas=atlas,
107
+ negative_limit=-2 * u.nm,
108
+ positive_limit=2 * u.nm,
109
+ num_steps=550,
110
+ )
111
+ logger.info(f"{crval = !s}")
112
+
113
+ with self.apm_task_step("Set up wavelength fit"):
114
+ logger.info("Setting bounds")
115
+ bounds = BoundsModel(
116
+ crval=LengthBoundRange(min=crval - (5 * u.nm), max=crval + (5 * u.nm)),
117
+ dispersion=DispersionBoundRange(
118
+ min=dispersion - (0.05 * u.nm / u.pix), max=dispersion + (0.05 * u.nm / u.pix)
119
+ ),
120
+ incident_light_angle=AngleBoundRange(
121
+ min=incident_light_angle - (180 * u.deg),
122
+ max=incident_light_angle + (180 * u.deg),
123
+ ),
124
+ resolving_power=UnitlessBoundRange(
125
+ min=resolving_power - (resolving_power * 0.1),
126
+ max=resolving_power + (resolving_power * 0.1),
127
+ ),
128
+ opacity_factor=UnitlessBoundRange(min=0.0, max=10.0),
129
+ straylight_fraction=UnitlessBoundRange(min=0.0, max=0.4),
130
+ )
131
+
132
+ logger.info("Initializing parameters")
133
+ input_parameters = WavelengthCalibrationParameters(
134
+ crval=crval,
135
+ dispersion=dispersion,
136
+ incident_light_angle=incident_light_angle,
137
+ resolving_power=resolving_power,
138
+ opacity_factor=5.0,
139
+ straylight_fraction=0.2,
140
+ grating_constant=self.constants.grating_constant,
141
+ doppler_velocity=doppler_velocity,
142
+ order=order,
143
+ bounds=bounds,
144
+ )
145
+
146
+ # Define spectral weights to apply
147
+ weights = np.ones_like(input_spectrum)
148
+ # Set edge weights to zero to mitigate flat field artifacts (inner and outer 10% of array)
149
+ num_pixels = len(weights)
150
+ weights[: num_pixels // self.parameters.wavecal_fraction_of_unweighted_edge_pixels] = 0
151
+ weights[-num_pixels // self.parameters.wavecal_fraction_of_unweighted_edge_pixels :] = 0
152
+
153
+ fitter = WavelengthCalibrationFitter(
154
+ input_parameters=input_parameters,
155
+ atlas=atlas,
156
+ )
157
+
158
+ logger.info(f"Input parameters: {input_parameters.lmfit_parameters.pretty_repr()}")
159
+
160
+ with self.apm_processing_step("Run wavelength solution fit"):
161
+ fit_result = fitter(
162
+ input_wavelength_vector=input_wavelength_vector,
163
+ input_spectrum=input_spectrum,
164
+ spectral_weights=weights,
165
+ )
166
+
167
+ with self.apm_writing_step("Save wavelength solution and quality metrics"):
168
+ self.write(
169
+ data=fit_result.wavelength_parameters.to_header(
170
+ axis_num=1, add_alternate_keys=True
171
+ ),
172
+ tags=[CryonirspTag.task_spectral_fit(), CryonirspTag.intermediate()],
173
+ encoder=json_encoder,
174
+ )
175
+ self.quality_store_wavecal_results(
176
+ input_wavelength=input_wavelength_vector,
177
+ input_spectrum=input_spectrum,
178
+ fit_result=fit_result,
179
+ weights=weights,
180
+ )
181
+
182
+ def get_theoretical_dispersion_order_light_angle(self) -> tuple[u.Quantity, int, u.Quantity]:
183
+ # TODO: Make this docstring correct (we use grating constant, not grating spacing) and show calculation of all values.
184
+ r"""
185
+ Compute theoretical dispersion, spectral order, and incident light angle.
186
+
187
+ The incident light angle, :math:`\alpha`, is computed as
188
+
189
+ .. math::
190
+ \alpha = \phi + \theta_L
191
+
192
+ where :math:`\phi`, the grating position, and :math:`\theta_L`, the Littrow angle, come from L0 headers.
193
+
194
+ From the grating equation, the spectral order, :math:`m`, is
195
+
196
+ .. math::
197
+ m = \frac{\sin\alpha + \sin\beta}{\sigma\lambda}
198
+
199
+ where :math:`\sigma` is the grating constant (lines per mm), :math:`\beta` is the diffracted light angle,
200
+ and :math:`\lambda` is the wavelength. The wavelength comes from L0 headers and :math:`\beta = \phi - \theta_L`.
201
+
202
+ Finally, the linear dispersion (nm / px) is
203
+
204
+ .. math::
205
+ \frac{d\lambda}{dl} = \frac{\mu \cos\beta}{m \sigma f}
206
+
207
+ where :math:`\mu` is the detector pixel pitch and :math:`f` is the camera focal length, both of which come from
208
+ L0 headers.
209
+ """
210
+ wavelength = self.constants.wavelength * u.nanometer
211
+ grating_position_angle_phi = self.constants.grating_position_deg * u.deg
212
+ grating_littrow_angle_theta = self.constants.grating_littrow_angle_deg * u.deg
213
+ incident_light_angle = grating_position_angle_phi + grating_littrow_angle_theta
214
+ beta = grating_position_angle_phi - grating_littrow_angle_theta
215
+ order = int(
216
+ (np.sin(incident_light_angle) + np.sin(beta))
217
+ / (wavelength * self.constants.grating_constant)
218
+ )
219
+ camera_mirror_focal_length = self.parameters.camera_mirror_focal_length_mm
220
+ pixpitch = self.parameters.pixel_pitch_micron
221
+ linear_disp = (
222
+ order * (self.constants.grating_constant / np.cos(beta)) * camera_mirror_focal_length
223
+ )
224
+ theoretical_dispersion = (pixpitch / linear_disp).to(u.nanometer) / u.pix
225
+
226
+ logger.info(f"{theoretical_dispersion = !s}")
227
+ logger.info(f"{order = }")
228
+ logger.info(f"{incident_light_angle = !s}")
229
+
230
+ return theoretical_dispersion, order, incident_light_angle
231
+
232
+ def compute_input_wavelength_vector(
233
+ self,
234
+ *,
235
+ spectrum: np.ndarray,
236
+ dispersion: u.Quantity,
237
+ order: int,
238
+ incident_light_angle: u.Quantity,
239
+ ) -> u.Quantity:
240
+ """Compute the expected wavelength vector based on the header information."""
241
+ num_wave_pix = spectrum.size
242
+ wavelength_parameters = WavelengthParameters(
243
+ crpix=num_wave_pix // 2 + 1,
244
+ crval=self.constants.wavelength,
245
+ dispersion=dispersion.to_value(u.nm / u.pix),
246
+ grating_constant=self.constants.grating_constant.to_value(1 / u.mm),
247
+ order=order,
248
+ incident_light_angle=incident_light_angle.to_value(u.deg),
249
+ cunit="nm",
250
+ )
251
+ header = wavelength_parameters.to_header(axis_num=1)
252
+ wcs = WCS(header)
253
+ input_wavelength_vector = wcs.spectral.pixel_to_world(np.arange(num_wave_pix)).to(u.nm)
254
+
255
+ return input_wavelength_vector
256
+
257
+ def get_doppler_velocity(self) -> u.Quantity:
258
+ """Find the speed at which DKIST is moving relative to the Sun's center.
259
+
260
+ Positive values refer to when DKIST is moving away from the sun.
261
+ """
262
+ coord = location_of_dkist.get_gcrs(obstime=Time(self.constants.solar_gain_ip_start_time))
263
+ heliocentric_coord = coord.transform_to(
264
+ HeliocentricInertial(obstime=Time(self.constants.solar_gain_ip_start_time))
265
+ )
266
+ obs_vr_kms = heliocentric_coord.d_distance
267
+ return obs_vr_kms
268
+
269
+ def get_resolving_power(self) -> int:
270
+ """Find the resolving power for the slit and filter center wavelength used during observation."""
271
+ # Map of (center wavelength) → (slit width) → resolving power
272
+ resolving_power_map = {1080.0: {175: 39580, 52: 120671}, 1430.0: {175: 42943, 52: 133762}}
273
+
274
+ center_wavelength = self.constants.center_wavelength
275
+ slit_width = self.constants.slit_width
276
+
277
+ # Find the closest matching key within tolerance
278
+ matched_wavelength = next(
279
+ (
280
+ key
281
+ for key in resolving_power_map
282
+ if math.isclose(center_wavelength, key, abs_tol=10)
283
+ ),
284
+ None,
285
+ )
286
+
287
+ if matched_wavelength is None:
288
+ raise ValueError(
289
+ f"{center_wavelength} not a valid filter center wavelength. "
290
+ f"Should be within 10 nm of one of {', '.join(str(k) for k in resolving_power_map)} nm."
291
+ )
292
+
293
+ slit_dict = resolving_power_map[matched_wavelength]
294
+ if slit_width not in slit_dict:
295
+ raise ValueError(
296
+ f"{slit_width} not a valid slit width. "
297
+ f"Should be one of {', '.join(str(k) for k in slit_dict)} µm."
298
+ )
299
+
300
+ return slit_dict[slit_width]
@@ -11,7 +11,7 @@ from astropy.coordinates.builtin_frames.altaz import AltAz
11
11
  from astropy.coordinates.sky_coordinate import SkyCoord
12
12
  from astropy.io import fits
13
13
  from astropy.time.core import Time
14
- from dkist_processing_common.codecs.asdf import asdf_decoder
14
+ from dkist_processing_common.codecs.json import json_decoder
15
15
  from dkist_processing_common.models.dkist_location import location_of_dkist
16
16
  from dkist_processing_common.models.wavelength import WavelengthRange
17
17
  from dkist_processing_common.tasks import WriteL1Frame
@@ -1020,24 +1020,14 @@ class SPWriteL1Frame(CryonirspWriteL1Frame):
1020
1020
 
1021
1021
  @cached_property
1022
1022
  def spectral_fit_results(self) -> dict:
1023
- """Get the spectral fit results."""
1024
- fit_dict = next(
1023
+ """Get the spectral fit results from disk."""
1024
+ return next(
1025
1025
  self.read(
1026
- tags=[CryonirspTag.task_spectral_fit(), CryonirspTag.intermediate()],
1027
- decoder=asdf_decoder,
1026
+ tags=[CryonirspTag.intermediate(), CryonirspTag.task_spectral_fit()],
1027
+ decoder=json_decoder,
1028
1028
  )
1029
1029
  )
1030
- del fit_dict["asdf_library"]
1031
- del fit_dict["history"]
1032
- return fit_dict
1033
1030
 
1034
1031
  def update_spectral_headers(self, header: fits.Header):
1035
1032
  """Update spectral headers after spectral correction."""
1036
- for key, value in self.spectral_fit_results.items():
1037
- # update the headers
1038
- header[key] = value
1039
-
1040
- header["CTYPE1"] = "AWAV-GRA"
1041
- header["CUNIT1"] = "nm"
1042
- header["CTYPE1A"] = "AWAV-GRA"
1043
- header["CUNIT1A"] = "nm"
1033
+ header.update(self.spectral_fit_results)
@@ -166,7 +166,7 @@ class CryonirspConstantsDb:
166
166
  "EXPERID3",
167
167
  )
168
168
  # These are SP defaults...
169
- AXIS_1_TYPE: str = "AWAV"
169
+ AXIS_1_TYPE: str = "AWAV-GRA"
170
170
  AXIS_2_TYPE: str = "HPLT-TAN"
171
171
  AXIS_3_TYPE: str = "HPLN-TAN"
172
172
  ROI_1_ORIGIN_X: int = 0
@@ -177,6 +177,8 @@ class CryonirspConstantsDb:
177
177
  GRATING_LITTROW_ANGLE_DEG: float = -5.5
178
178
  GRATING_CONSTANT: float = 31.6
179
179
  SOLAR_GAIN_IP_START_TIME: str = "2021-01-01T00:00:00"
180
+ CENTER_WAVELENGTH: float = 1074.9
181
+ SLIT_WIDTH: int = 175
180
182
 
181
183
 
182
184
  @pytest.fixture()
@@ -287,8 +289,6 @@ class FileParameter(InputDatasetFilePointer):
287
289
  # and in CryonirspTestingParameters.
288
290
  LINEARIZATION_THRESHOLDS_CI = "cryonirsp_linearization_thresholds_ci.npy"
289
291
  LINEARIZATION_THRESHOLDS_SP = "cryonirsp_linearization_thresholds_sp.npy"
290
- SOLAR_ATLAS = "cryonirsp_solar_atlas.npy"
291
- TELLURIC_ATLAS = "cryonirsp_telluric_atlas.npy"
292
292
 
293
293
 
294
294
  def _create_parameter_files(task: WorkflowTaskBase) -> None:
@@ -301,13 +301,6 @@ def _create_parameter_files(task: WorkflowTaskBase) -> None:
301
301
  data=thresh, tags=CryonirspTag.parameter(LINEARIZATION_THRESHOLDS_SP), encoder=array_encoder
302
302
  )
303
303
 
304
- # solar and telluric atlases
305
- atlas_wavelengths = range(300, 16600)
306
- atlas_transmission = np.random.rand(16300)
307
- atlas = [atlas_wavelengths, atlas_transmission]
308
- task.write(data=atlas, tags=CryonirspTag.parameter(SOLAR_ATLAS), encoder=array_encoder)
309
- task.write(data=atlas, tags=CryonirspTag.parameter(TELLURIC_ATLAS), encoder=array_encoder)
310
-
311
304
 
312
305
  TestingParameters = TypeVar("TestingParameters", bound="CryonirspTestingParameters")
313
306
 
@@ -380,18 +373,18 @@ def cryonirsp_testing_parameters_factory(
380
373
  cryonirsp_linearization_optical_density_filter_attenuation_g408: WavelengthParameter = (
381
374
  WavelengthParameter(values=(-4.26, -4.26, -4.26, -4.26))
382
375
  )
383
- cryonirsp_solar_atlas: FileParameter = field(
384
- default_factory=lambda: FileParameter(
385
- object_key=SOLAR_ATLAS,
386
- )
387
- )
388
- cryonirsp_telluric_atlas: FileParameter = field(
389
- default_factory=lambda: FileParameter(
390
- object_key=TELLURIC_ATLAS,
391
- )
392
- )
393
376
  cryonirsp_camera_mirror_focal_length_mm: float = 932.0
394
377
  cryonirsp_pixel_pitch_micron: float = 18.0
378
+ cryonirsp_wavecal_atlas_download_config: dict[str, str] = field(
379
+ default_factory=lambda: {
380
+ "base_url": "doi:10.5281/zenodo.14646787/",
381
+ "telluric_reference_atlas_file_name": "telluric_reference_atlas.npy",
382
+ "telluric_reference_atlas_hash_id": "md5:8db5e12508b293bca3495d81a0747447",
383
+ "solar_reference_atlas_file_name": "solar_reference_atlas.npy",
384
+ "solar_reference_atlas_hash_id": "md5:84ab4c50689ef235fe5ed4f7ee905ca0",
385
+ }
386
+ )
387
+ cryonirsp_wavecal_fraction_of_unweighted_edge_pixels: int = 10
395
388
 
396
389
  return CryonirspTestingParameters
397
390
 
@@ -374,6 +374,8 @@ class SimpleModulatedHeaders(CryonirspHeaders):
374
374
  ),
375
375
  start_date: str = "2023-01-01T01:23:45",
376
376
  modstate_length_sec: float = 0.5,
377
+ center_wavelength: float = 1080.0,
378
+ slit_width: float = 52.0,
377
379
  ):
378
380
  dataset_shape = (1, *array_shape)
379
381
  super().__init__(
@@ -395,9 +397,11 @@ class SimpleModulatedHeaders(CryonirspHeaders):
395
397
  self.add_constant_key("CRSP_042", modstate)
396
398
  self.add_constant_key("CAM__004", exposure_condition.exposure_time)
397
399
  self.add_constant_key("CRSP_048", exposure_condition.filter_name)
400
+ self.add_constant_key("CRSP_053", center_wavelength)
398
401
  self.add_constant_key("CRSP_074", grating_angle_deg)
399
402
  self.add_constant_key("CRSP_079", grating_littrow_angle)
400
403
  self.add_constant_key("CRSP_077", grating_constant)
404
+ self.add_constant_key("CRSP_082", slit_width)
401
405
  self.add_constant_key("CRVAL1", CRVAL1)
402
406
  self.add_constant_key("CRPIX1", CRPIX1)
403
407
  self.add_constant_key("CDELT1", CDELT1)
@@ -445,6 +449,8 @@ class ModulatedSolarGainHeaders(SimpleModulatedHeaders):
445
449
  modstate_length_sec: float = 0.5,
446
450
  num_modstates: int = 1,
447
451
  modstate: int = 1,
452
+ center_wavelength: float = 1080.0,
453
+ slit_width: float = 52.0,
448
454
  ):
449
455
  super().__init__(
450
456
  num_modstates=num_modstates,
@@ -454,6 +460,8 @@ class ModulatedSolarGainHeaders(SimpleModulatedHeaders):
454
460
  exposure_condition=exposure_condition,
455
461
  start_date=start_date,
456
462
  modstate_length_sec=modstate_length_sec,
463
+ center_wavelength=center_wavelength,
464
+ slit_width=slit_width,
457
465
  )
458
466
 
459
467
  self.add_constant_key("PAC__002", "clear")
@@ -565,6 +573,8 @@ class ModulatedObserveHeaders(SimpleModulatedHeaders):
565
573
  start_date: str = "2023-01-01T01:23:45",
566
574
  modstate_length_sec: float = 0.5,
567
575
  meas_num: int = 1,
576
+ center_wavelength: float = 1080.0,
577
+ slit_width: float = 52.0,
568
578
  ):
569
579
  super().__init__(
570
580
  num_modstates=num_modstates,
@@ -574,6 +584,8 @@ class ModulatedObserveHeaders(SimpleModulatedHeaders):
574
584
  exposure_condition=exposure_condition,
575
585
  start_date=start_date,
576
586
  modstate_length_sec=modstate_length_sec,
587
+ center_wavelength=center_wavelength,
588
+ slit_width=slit_width,
577
589
  )
578
590
 
579
591
  self.num_map_scans = num_map_scans