dkist-processing-cryonirsp 1.4.20__py3-none-any.whl → 1.14.9rc1__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 (95) hide show
  1. changelog/232.misc.rst +1 -0
  2. dkist_processing_cryonirsp/__init__.py +1 -0
  3. dkist_processing_cryonirsp/codecs/fits.py +1 -0
  4. dkist_processing_cryonirsp/config.py +5 -1
  5. dkist_processing_cryonirsp/models/beam_boundaries.py +1 -0
  6. dkist_processing_cryonirsp/models/constants.py +31 -30
  7. dkist_processing_cryonirsp/models/exposure_conditions.py +6 -5
  8. dkist_processing_cryonirsp/models/fits_access.py +40 -0
  9. dkist_processing_cryonirsp/models/parameters.py +14 -26
  10. dkist_processing_cryonirsp/models/tags.py +1 -0
  11. dkist_processing_cryonirsp/models/task_name.py +1 -0
  12. dkist_processing_cryonirsp/parsers/check_for_gains.py +1 -0
  13. dkist_processing_cryonirsp/parsers/cryonirsp_l0_fits_access.py +40 -47
  14. dkist_processing_cryonirsp/parsers/cryonirsp_l1_fits_access.py +1 -0
  15. dkist_processing_cryonirsp/parsers/exposure_conditions.py +14 -13
  16. dkist_processing_cryonirsp/parsers/map_repeats.py +1 -0
  17. dkist_processing_cryonirsp/parsers/measurements.py +29 -16
  18. dkist_processing_cryonirsp/parsers/modstates.py +5 -1
  19. dkist_processing_cryonirsp/parsers/optical_density_filters.py +1 -0
  20. dkist_processing_cryonirsp/parsers/polarimetric_check.py +18 -7
  21. dkist_processing_cryonirsp/parsers/scan_step.py +12 -4
  22. dkist_processing_cryonirsp/parsers/time.py +7 -7
  23. dkist_processing_cryonirsp/parsers/wavelength.py +6 -1
  24. dkist_processing_cryonirsp/tasks/__init__.py +2 -1
  25. dkist_processing_cryonirsp/tasks/assemble_movie.py +1 -0
  26. dkist_processing_cryonirsp/tasks/bad_pixel_map.py +6 -5
  27. dkist_processing_cryonirsp/tasks/beam_boundaries_base.py +12 -11
  28. dkist_processing_cryonirsp/tasks/ci_beam_boundaries.py +1 -0
  29. dkist_processing_cryonirsp/tasks/ci_science.py +1 -0
  30. dkist_processing_cryonirsp/tasks/cryonirsp_base.py +2 -3
  31. dkist_processing_cryonirsp/tasks/dark.py +5 -4
  32. dkist_processing_cryonirsp/tasks/gain.py +7 -6
  33. dkist_processing_cryonirsp/tasks/instrument_polarization.py +17 -16
  34. dkist_processing_cryonirsp/tasks/l1_output_data.py +1 -0
  35. dkist_processing_cryonirsp/tasks/linearity_correction.py +1 -0
  36. dkist_processing_cryonirsp/tasks/make_movie_frames.py +3 -2
  37. dkist_processing_cryonirsp/tasks/mixin/corrections.py +1 -0
  38. dkist_processing_cryonirsp/tasks/mixin/shift_measurements.py +9 -2
  39. dkist_processing_cryonirsp/tasks/parse.py +70 -52
  40. dkist_processing_cryonirsp/tasks/quality_metrics.py +15 -14
  41. dkist_processing_cryonirsp/tasks/science_base.py +8 -6
  42. dkist_processing_cryonirsp/tasks/sp_beam_boundaries.py +2 -1
  43. dkist_processing_cryonirsp/tasks/sp_geometric.py +11 -10
  44. dkist_processing_cryonirsp/tasks/sp_science.py +1 -0
  45. dkist_processing_cryonirsp/tasks/sp_solar_gain.py +15 -12
  46. dkist_processing_cryonirsp/tasks/sp_wavelength_calibration.py +300 -0
  47. dkist_processing_cryonirsp/tasks/write_l1.py +59 -38
  48. dkist_processing_cryonirsp/tests/conftest.py +75 -53
  49. dkist_processing_cryonirsp/tests/header_models.py +62 -11
  50. dkist_processing_cryonirsp/tests/local_trial_workflows/l0_cals_only.py +26 -46
  51. dkist_processing_cryonirsp/tests/local_trial_workflows/l0_to_l1.py +26 -47
  52. dkist_processing_cryonirsp/tests/local_trial_workflows/linearize_only.py +3 -3
  53. dkist_processing_cryonirsp/tests/local_trial_workflows/local_trial_helpers.py +57 -26
  54. dkist_processing_cryonirsp/tests/test_assemble_movie.py +4 -5
  55. dkist_processing_cryonirsp/tests/test_assemble_qualilty.py +5 -1
  56. dkist_processing_cryonirsp/tests/test_bad_pixel_maps.py +4 -5
  57. dkist_processing_cryonirsp/tests/test_ci_beam_boundaries.py +4 -5
  58. dkist_processing_cryonirsp/tests/test_ci_science.py +4 -5
  59. dkist_processing_cryonirsp/tests/test_corrections.py +5 -6
  60. dkist_processing_cryonirsp/tests/test_cryo_base.py +4 -6
  61. dkist_processing_cryonirsp/tests/test_cryo_constants.py +7 -3
  62. dkist_processing_cryonirsp/tests/test_dark.py +7 -8
  63. dkist_processing_cryonirsp/tests/test_fits_access.py +44 -0
  64. dkist_processing_cryonirsp/tests/test_gain.py +7 -8
  65. dkist_processing_cryonirsp/tests/test_instrument_polarization.py +19 -10
  66. dkist_processing_cryonirsp/tests/test_linearity_correction.py +5 -4
  67. dkist_processing_cryonirsp/tests/test_make_movie_frames.py +2 -3
  68. dkist_processing_cryonirsp/tests/test_parameters.py +23 -28
  69. dkist_processing_cryonirsp/tests/test_parse.py +48 -12
  70. dkist_processing_cryonirsp/tests/test_quality.py +2 -3
  71. dkist_processing_cryonirsp/tests/test_sp_beam_boundaries.py +5 -5
  72. dkist_processing_cryonirsp/tests/test_sp_geometric.py +5 -6
  73. dkist_processing_cryonirsp/tests/test_sp_make_movie_frames.py +2 -3
  74. dkist_processing_cryonirsp/tests/test_sp_science.py +4 -5
  75. dkist_processing_cryonirsp/tests/test_sp_solar.py +6 -5
  76. dkist_processing_cryonirsp/tests/{test_sp_dispersion_axis_correction.py → test_sp_wavelength_calibration.py} +11 -29
  77. dkist_processing_cryonirsp/tests/test_trial_create_quality_report.py +1 -1
  78. dkist_processing_cryonirsp/tests/test_workflows.py +1 -0
  79. dkist_processing_cryonirsp/tests/test_write_l1.py +29 -31
  80. dkist_processing_cryonirsp/workflows/__init__.py +1 -0
  81. dkist_processing_cryonirsp/workflows/ci_l0_processing.py +9 -5
  82. dkist_processing_cryonirsp/workflows/sp_l0_processing.py +12 -8
  83. dkist_processing_cryonirsp/workflows/trial_workflows.py +12 -11
  84. dkist_processing_cryonirsp-1.14.9rc1.dist-info/METADATA +552 -0
  85. dkist_processing_cryonirsp-1.14.9rc1.dist-info/RECORD +115 -0
  86. {dkist_processing_cryonirsp-1.4.20.dist-info → dkist_processing_cryonirsp-1.14.9rc1.dist-info}/WHEEL +1 -1
  87. docs/ci_science_calibration.rst +10 -0
  88. docs/conf.py +1 -0
  89. docs/index.rst +1 -0
  90. docs/sp_science_calibration.rst +7 -0
  91. docs/wavelength_calibration.rst +62 -0
  92. dkist_processing_cryonirsp/tasks/sp_dispersion_axis_correction.py +0 -492
  93. dkist_processing_cryonirsp-1.4.20.dist-info/METADATA +0 -452
  94. dkist_processing_cryonirsp-1.4.20.dist-info/RECORD +0 -111
  95. {dkist_processing_cryonirsp-1.4.20.dist-info → dkist_processing_cryonirsp-1.14.9rc1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,300 @@
1
+ """Cryo SP wavelength calibration task. See :doc:`this page </wavelength_calibration>` for more information."""
2
+
3
+ import math
4
+
5
+ import astropy.units as u
6
+ import numpy as np
7
+ from astropy.time import Time
8
+ from astropy.wcs import WCS
9
+ from dkist_processing_common.codecs.json import json_encoder
10
+ from dkist_processing_common.models.dkist_location import location_of_dkist
11
+ from dkist_service_configuration.logging import logger
12
+ from solar_wavelength_calibration import Atlas
13
+ from solar_wavelength_calibration import WavelengthCalibrationFitter
14
+ from solar_wavelength_calibration.fitter.parameters import AngleBoundRange
15
+ from solar_wavelength_calibration.fitter.parameters import BoundsModel
16
+ from solar_wavelength_calibration.fitter.parameters import DispersionBoundRange
17
+ from solar_wavelength_calibration.fitter.parameters import LengthBoundRange
18
+ from solar_wavelength_calibration.fitter.parameters import UnitlessBoundRange
19
+ from solar_wavelength_calibration.fitter.parameters import WavelengthCalibrationParameters
20
+ from solar_wavelength_calibration.fitter.wavelength_fitter import WavelengthParameters
21
+ from solar_wavelength_calibration.fitter.wavelength_fitter import calculate_initial_crval_guess
22
+ from sunpy.coordinates import HeliocentricInertial
23
+
24
+ from dkist_processing_cryonirsp.codecs.fits import cryo_fits_array_decoder
25
+ from dkist_processing_cryonirsp.models.tags import CryonirspTag
26
+ from dkist_processing_cryonirsp.tasks.cryonirsp_base import CryonirspTaskBase
27
+
28
+ __all__ = ["SPWavelengthCalibration"]
29
+
30
+
31
+ class SPWavelengthCalibration(CryonirspTaskBase):
32
+ """Task class for correcting the dispersion axis wavelength values.
33
+
34
+ Parameters
35
+ ----------
36
+ recipe_run_id : int
37
+ id of the recipe run used to identify the workflow run this task is part of
38
+ workflow_name : str
39
+ name of the workflow to which this instance of the task belongs
40
+ workflow_version : str
41
+ version of the workflow to which this instance of the task belongs
42
+
43
+ """
44
+
45
+ record_provenance = True
46
+
47
+ def run(self) -> None:
48
+ """
49
+ Run method for the task.
50
+
51
+ - Gather 1D characteristic spectrum. This will be the initial spectrum.
52
+ - Get a header from a solar gain frame for initial wavelength estimation.
53
+ - Compute the theoretical dispersion, order, and incident light angle.
54
+ - Compute the input wavelength vector from the spectrum and header.
55
+ - Get the Doppler velocity and resolving power.
56
+ - Define fitting bounds and initialize model parameters.
57
+ - Set and normalize spectral weights, zeroing edges.
58
+ - Fit the profile using WavelengthCalibrationFitter.
59
+ - Write fit results to disk.
60
+
61
+ Returns
62
+ -------
63
+ None
64
+ """
65
+ with self.telemetry_span("Load input spectrum and wavelength"):
66
+ logger.info("Loading input spectrum")
67
+ input_spectrum = next(
68
+ self.read(
69
+ tags=[
70
+ CryonirspTag.intermediate_frame(beam=1),
71
+ CryonirspTag.task_characteristic_spectra(),
72
+ ],
73
+ decoder=cryo_fits_array_decoder,
74
+ )
75
+ )
76
+
77
+ logger.info(
78
+ "Computing instrument specific dispersion, order, and incident light angle."
79
+ )
80
+ (
81
+ dispersion,
82
+ order,
83
+ incident_light_angle,
84
+ ) = self.get_theoretical_dispersion_order_light_angle()
85
+
86
+ logger.info("Computing initial wavelength vector.")
87
+ input_wavelength_vector = self.compute_input_wavelength_vector(
88
+ spectrum=input_spectrum,
89
+ dispersion=dispersion,
90
+ order=order,
91
+ incident_light_angle=incident_light_angle,
92
+ )
93
+
94
+ # Get the doppler velocity
95
+ doppler_velocity = self.get_doppler_velocity()
96
+ logger.info(f"{doppler_velocity = !s}")
97
+
98
+ # Get the resolving power
99
+ resolving_power = self.get_resolving_power()
100
+ logger.info(f"{resolving_power = }")
101
+
102
+ with self.telemetry_span("Compute brute-force CRVAL initial guess"):
103
+ atlas = Atlas(config=self.parameters.wavecal_atlas_download_config)
104
+ crval = calculate_initial_crval_guess(
105
+ input_wavelength_vector=input_wavelength_vector,
106
+ input_spectrum=input_spectrum,
107
+ atlas=atlas,
108
+ negative_limit=-2 * u.nm,
109
+ positive_limit=2 * u.nm,
110
+ num_steps=550,
111
+ )
112
+ logger.info(f"{crval = !s}")
113
+
114
+ with self.telemetry_span("Set up wavelength fit"):
115
+ logger.info("Setting bounds")
116
+ bounds = BoundsModel(
117
+ crval=LengthBoundRange(min=crval - (5 * u.nm), max=crval + (5 * u.nm)),
118
+ dispersion=DispersionBoundRange(
119
+ min=dispersion - (0.05 * u.nm / u.pix), max=dispersion + (0.05 * u.nm / u.pix)
120
+ ),
121
+ incident_light_angle=AngleBoundRange(
122
+ min=incident_light_angle - (180 * u.deg),
123
+ max=incident_light_angle + (180 * u.deg),
124
+ ),
125
+ resolving_power=UnitlessBoundRange(
126
+ min=resolving_power - (resolving_power * 0.1),
127
+ max=resolving_power + (resolving_power * 0.1),
128
+ ),
129
+ opacity_factor=UnitlessBoundRange(min=0.0, max=10.0),
130
+ straylight_fraction=UnitlessBoundRange(min=0.0, max=0.4),
131
+ )
132
+
133
+ logger.info("Initializing parameters")
134
+ input_parameters = WavelengthCalibrationParameters(
135
+ crval=crval,
136
+ dispersion=dispersion,
137
+ incident_light_angle=incident_light_angle,
138
+ resolving_power=resolving_power,
139
+ opacity_factor=5.0,
140
+ straylight_fraction=0.2,
141
+ grating_constant=self.constants.grating_constant,
142
+ doppler_velocity=doppler_velocity,
143
+ order=order,
144
+ bounds=bounds,
145
+ )
146
+
147
+ # Define spectral weights to apply
148
+ weights = np.ones_like(input_spectrum)
149
+ # Set edge weights to zero to mitigate flat field artifacts (inner and outer 10% of array)
150
+ num_pixels = len(weights)
151
+ weights[: num_pixels // self.parameters.wavecal_fraction_of_unweighted_edge_pixels] = 0
152
+ weights[-num_pixels // self.parameters.wavecal_fraction_of_unweighted_edge_pixels :] = 0
153
+
154
+ fitter = WavelengthCalibrationFitter(
155
+ input_parameters=input_parameters,
156
+ atlas=atlas,
157
+ )
158
+
159
+ logger.info(f"Input parameters: {input_parameters.lmfit_parameters.pretty_repr()}")
160
+
161
+ with self.telemetry_span("Run wavelength solution fit"):
162
+ fit_result = fitter(
163
+ input_spectrum=input_spectrum,
164
+ spectral_weights=weights,
165
+ )
166
+
167
+ with self.telemetry_span("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_start_time))
263
+ heliocentric_coord = coord.transform_to(
264
+ HeliocentricInertial(obstime=Time(self.constants.solar_gain_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]
@@ -1,4 +1,5 @@
1
1
  """Cryonirsp write L1 task."""
2
+
2
3
  from abc import ABC
3
4
  from abc import abstractmethod
4
5
  from functools import cached_property
@@ -11,21 +12,23 @@ from astropy.coordinates.builtin_frames.altaz import AltAz
11
12
  from astropy.coordinates.sky_coordinate import SkyCoord
12
13
  from astropy.io import fits
13
14
  from astropy.time.core import Time
14
- from dkist_processing_common.codecs.asdf import asdf_decoder
15
+ from dkist_processing_common.codecs.json import json_decoder
16
+ from dkist_processing_common.models.dkist_location import location_of_dkist
17
+ from dkist_processing_common.models.fits_access import MetadataKey
15
18
  from dkist_processing_common.models.wavelength import WavelengthRange
16
19
  from dkist_processing_common.tasks import WriteL1Frame
17
- from dkist_processing_common.tasks.mixin.input_dataset import InputDatasetMixin
18
20
  from sunpy.coordinates import GeocentricEarthEquatorial
19
21
  from sunpy.coordinates import Helioprojective
20
22
 
21
23
  from dkist_processing_cryonirsp.models.constants import CryonirspConstants
24
+ from dkist_processing_cryonirsp.models.fits_access import CryonirspMetadataKey
22
25
  from dkist_processing_cryonirsp.models.parameters import CryonirspParameters
23
26
  from dkist_processing_cryonirsp.models.tags import CryonirspTag
24
27
 
25
28
  __all__ = ["CIWriteL1Frame", "SPWriteL1Frame"]
26
29
 
27
30
 
28
- class CryonirspWriteL1Frame(WriteL1Frame, ABC, InputDatasetMixin):
31
+ class CryonirspWriteL1Frame(WriteL1Frame, ABC):
29
32
  """
30
33
  Task class for writing out calibrated l1 CryoNIRSP frames.
31
34
 
@@ -51,7 +54,7 @@ class CryonirspWriteL1Frame(WriteL1Frame, ABC, InputDatasetMixin):
51
54
  workflow_version=workflow_version,
52
55
  )
53
56
  self.parameters = CryonirspParameters(
54
- self.input_dataset_parameters,
57
+ scratch=self.scratch,
55
58
  obs_ip_start_time=self.constants.obs_ip_start_time,
56
59
  wavelength=self.constants.wavelength,
57
60
  arm_id=self.constants.arm_id,
@@ -92,13 +95,13 @@ class CryonirspWriteL1Frame(WriteL1Frame, ABC, InputDatasetMixin):
92
95
  next_axis = self.add_map_scan_axis(header, axis_num=next_axis)
93
96
  if self.constants.correct_for_polarization:
94
97
  next_axis = self.add_stokes_axis(header, stokes=stokes, axis_num=next_axis)
95
- self.add_wavelength_headers(header)
96
98
  last_axis = next_axis - 1
97
99
  self.add_common_headers(header, num_axes=last_axis)
98
100
  self.flip_spectral_axis(header)
99
101
  boresight_coordinates = self.get_boresight_coords(header)
100
102
  self.correct_spatial_wcs_info(header, boresight_coordinates)
101
103
  self.update_spectral_headers(header)
104
+ self.add_wavelength_headers(header)
102
105
 
103
106
  return header
104
107
 
@@ -141,10 +144,9 @@ class CryonirspWriteL1Frame(WriteL1Frame, ABC, InputDatasetMixin):
141
144
  header[f"DTYPE{axis_num}"] = "TEMPORAL"
142
145
  header[f"DPNAME{axis_num}"] = "measurement number"
143
146
  header[f"DWNAME{axis_num}"] = "time"
144
- header[f"CNAME{axis_num}"] = "time"
145
147
  header[f"DUNIT{axis_num}"] = "s"
146
148
  # DINDEX and CNCMEAS are both one-based
147
- header[f"DINDEX{axis_num}"] = header["CNCMEAS"]
149
+ header[f"DINDEX{axis_num}"] = header[CryonirspMetadataKey.meas_num]
148
150
  next_axis = axis_num + 1
149
151
  return next_axis
150
152
 
@@ -159,7 +161,6 @@ class CryonirspWriteL1Frame(WriteL1Frame, ABC, InputDatasetMixin):
159
161
  header[f"DTYPE{axis_num}"] = "TEMPORAL"
160
162
  header[f"DPNAME{axis_num}"] = "map scan number"
161
163
  header[f"DWNAME{axis_num}"] = "time"
162
- header[f"CNAME{axis_num}"] = "time"
163
164
  header[f"DUNIT{axis_num}"] = "s"
164
165
  # Temporal position in dataset
165
166
  # DINDEX and CNMAP are both one-based
@@ -175,7 +176,6 @@ class CryonirspWriteL1Frame(WriteL1Frame, ABC, InputDatasetMixin):
175
176
  header[f"DTYPE{axis_num}"] = "STOKES"
176
177
  header[f"DPNAME{axis_num}"] = "polarization state"
177
178
  header[f"DWNAME{axis_num}"] = "polarization state"
178
- header[f"CNAME{axis_num}"] = "polarization state"
179
179
  header[f"DUNIT{axis_num}"] = ""
180
180
  # Stokes position in dataset - stokes axis goes from 1-4
181
181
  header[f"DINDEX{axis_num}"] = self.constants.stokes_params.index(stokes.upper()) + 1
@@ -204,6 +204,9 @@ class CryonirspWriteL1Frame(WriteL1Frame, ABC, InputDatasetMixin):
204
204
  header["NBIN3"] = 1
205
205
  header["NBIN"] = header["NBIN1"] * header["NBIN2"] * header["NBIN3"]
206
206
 
207
+ # Values don't have any units because they are relative to disk center
208
+ header["BUNIT"] = ("", "Values are relative to disk center. See calibration docs.")
209
+
207
210
  def calculate_date_end(self, header: fits.Header) -> str:
208
211
  """
209
212
  In CryoNIRSP, the instrument specific DATE-END keyword is calculated during science calibration.
@@ -277,16 +280,16 @@ class CryonirspWriteL1Frame(WriteL1Frame, ABC, InputDatasetMixin):
277
280
  -------
278
281
  None
279
282
  """
280
- t0 = Time(header["DATE-BEG"])
283
+ t0 = Time(header[MetadataKey.time_obs])
281
284
  sky_coordinates = SkyCoord(
282
285
  boresight_coordinates[0] * u.arcsec,
283
286
  boresight_coordinates[1] * u.arcsec,
284
287
  obstime=t0,
285
- observer=self.location_of_dkist.get_itrs(t0),
288
+ observer=location_of_dkist.get_itrs(t0),
286
289
  frame="helioprojective",
287
290
  )
288
291
 
289
- frame_altaz = AltAz(obstime=t0, location=self.location_of_dkist)
292
+ frame_altaz = AltAz(obstime=t0, location=location_of_dkist)
290
293
 
291
294
  # Find angle between zenith and solar north at the center boresight pointing
292
295
  solar_orientation_angle = self.get_solar_orientation_angle(
@@ -348,7 +351,7 @@ class CryonirspWriteL1Frame(WriteL1Frame, ABC, InputDatasetMixin):
348
351
  boresight_coordinates[0] * u.arcsec,
349
352
  (boresight_coordinates[1] + 1) * u.arcsec,
350
353
  obstime=t0,
351
- observer=self.location_of_dkist.get_itrs(t0),
354
+ observer=location_of_dkist.get_itrs(t0),
352
355
  frame="helioprojective",
353
356
  )
354
357
 
@@ -396,7 +399,7 @@ class CryonirspWriteL1Frame(WriteL1Frame, ABC, InputDatasetMixin):
396
399
  """Get CryoNIRSP slit orientation measured relative to solar north at time of observation."""
397
400
  with Helioprojective.assume_spherical_screen(sky_coordinates.observer):
398
401
  sc_alt = sky_coordinates.transform_to(frame_altaz)
399
- coude_minus_azimuth_elevation = header["TTBLANGL"] * u.deg - (
402
+ coude_minus_azimuth_elevation = header[MetadataKey.table_angle] * u.deg - (
400
403
  (sc_alt.az.deg - sc_alt.alt.deg) * u.deg
401
404
  )
402
405
  cryo_instrument_alignment_angle = self.parameters.cryo_instrument_alignment_angle
@@ -470,6 +473,32 @@ class CryonirspWriteL1Frame(WriteL1Frame, ABC, InputDatasetMixin):
470
473
  """Update spectral headers after spectral correction."""
471
474
  pass
472
475
 
476
+ def add_timing_headers(self, header: fits.Header) -> fits.Header:
477
+ """
478
+ Add timing headers to the FITS header.
479
+
480
+ This method adds or updates headers related to frame timings.
481
+ """
482
+ # The source data is based on L0 data but L1 data takes L0 cadence * modstates * number of measurements to obtain.
483
+ # This causes the cadence to be num_modstates * num_measurements times longer.
484
+ # This causes the exposure time to be num_modstates times longer.
485
+ header["CADENCE"] = (
486
+ self.constants.average_cadence * self.constants.num_modstates * self.constants.num_meas
487
+ )
488
+ header["CADMIN"] = (
489
+ self.constants.minimum_cadence * self.constants.num_modstates * self.constants.num_meas
490
+ )
491
+ header["CADMAX"] = (
492
+ self.constants.maximum_cadence * self.constants.num_modstates * self.constants.num_meas
493
+ )
494
+ header["CADVAR"] = (
495
+ self.constants.variance_cadence * self.constants.num_modstates * self.constants.num_meas
496
+ )
497
+ header[MetadataKey.fpa_exposure_time_ms] = (
498
+ header[MetadataKey.fpa_exposure_time_ms] * self.constants.num_modstates
499
+ )
500
+ return header
501
+
473
502
 
474
503
  class CIWriteL1Frame(CryonirspWriteL1Frame):
475
504
  """
@@ -518,10 +547,11 @@ class CIWriteL1Frame(CryonirspWriteL1Frame):
518
547
  header[f"DTYPE{axis_num}"] = "TEMPORAL"
519
548
  header[f"DPNAME{axis_num}"] = "scan step number"
520
549
  header[f"DWNAME{axis_num}"] = "time"
521
- header[f"CNAME{axis_num}"] = "time"
550
+ if axis_num == 3:
551
+ header[f"CNAME{axis_num}"] = "time"
522
552
  header[f"DUNIT{axis_num}"] = "s"
523
553
  # DINDEX and CNCURSCN are both one-based
524
- header[f"DINDEX{axis_num}"] = header["CNCURSCN"]
554
+ header[f"DINDEX{axis_num}"] = header[CryonirspMetadataKey.scan_step]
525
555
  next_axis = axis_num + 1
526
556
  return next_axis
527
557
 
@@ -535,7 +565,7 @@ class CIWriteL1Frame(CryonirspWriteL1Frame):
535
565
 
536
566
  Range is the wavelengths at the edges of the filter full width half maximum.
537
567
  """
538
- filter_central_wavelength = header["CNCENWAV"] * u.nm
568
+ filter_central_wavelength = header[CryonirspMetadataKey.center_wavelength] * u.nm
539
569
  filter_fwhm = header["CNFWHM"] * u.nm
540
570
  return WavelengthRange(
541
571
  min=filter_central_wavelength - (filter_fwhm / 2),
@@ -695,7 +725,7 @@ class CIWriteL1Frame(CryonirspWriteL1Frame):
695
725
  observed_pix_x_rotated * u.arcsec,
696
726
  observed_pix_y_rotated * u.arcsec,
697
727
  obstime=t0,
698
- observer=self.location_of_dkist.get_itrs(t0),
728
+ observer=location_of_dkist.get_itrs(t0),
699
729
  frame="helioprojective",
700
730
  )
701
731
 
@@ -709,7 +739,7 @@ class CIWriteL1Frame(CryonirspWriteL1Frame):
709
739
  x * u.arcsec,
710
740
  y * u.arcsec,
711
741
  obstime=t0,
712
- observer=self.location_of_dkist.get_itrs(t0),
742
+ observer=location_of_dkist.get_itrs(t0),
713
743
  frame="helioprojective",
714
744
  )
715
745
  with Helioprojective.assume_spherical_screen(sky_coord.observer):
@@ -801,11 +831,12 @@ class SPWriteL1Frame(CryonirspWriteL1Frame):
801
831
  header[f"DTYPE{axis_num}"] = "SPATIAL"
802
832
  header[f"DPNAME{axis_num}"] = "scan step number"
803
833
  header[f"DWNAME{axis_num}"] = "helioprojective longitude"
804
- header[f"CNAME{axis_num}"] = "helioprojective longitude"
834
+ if axis_num == 3:
835
+ header[f"CNAME{axis_num}"] = "helioprojective longitude"
805
836
  # NB: CUNIT axis number is hard coded here
806
837
  header[f"DUNIT{axis_num}"] = header[f"CUNIT3"]
807
838
  # DINDEX and CNCURSCN are both one-based
808
- header[f"DINDEX{axis_num}"] = header["CNCURSCN"]
839
+ header[f"DINDEX{axis_num}"] = header[CryonirspMetadataKey.scan_step]
809
840
  next_axis = axis_num + 1
810
841
  return next_axis
811
842
 
@@ -966,7 +997,7 @@ class SPWriteL1Frame(CryonirspWriteL1Frame):
966
997
  observed_pix_x_rotated * u.arcsec,
967
998
  observed_pix_y_rotated * u.arcsec,
968
999
  obstime=t0,
969
- observer=self.location_of_dkist.get_itrs(t0),
1000
+ observer=location_of_dkist.get_itrs(t0),
970
1001
  frame="helioprojective",
971
1002
  )
972
1003
  with Helioprojective.assume_spherical_screen(sky_coord.observer):
@@ -979,7 +1010,7 @@ class SPWriteL1Frame(CryonirspWriteL1Frame):
979
1010
  x * u.arcsec,
980
1011
  y * u.arcsec,
981
1012
  obstime=t0,
982
- observer=self.location_of_dkist.get_itrs(t0),
1013
+ observer=location_of_dkist.get_itrs(t0),
983
1014
  frame="helioprojective",
984
1015
  )
985
1016
  with Helioprojective.assume_spherical_screen(sky_coord.observer):
@@ -1018,24 +1049,14 @@ class SPWriteL1Frame(CryonirspWriteL1Frame):
1018
1049
 
1019
1050
  @cached_property
1020
1051
  def spectral_fit_results(self) -> dict:
1021
- """Get the spectral fit results."""
1022
- fit_dict = next(
1052
+ """Get the spectral fit results from disk."""
1053
+ return next(
1023
1054
  self.read(
1024
- tags=[CryonirspTag.task_spectral_fit(), CryonirspTag.intermediate()],
1025
- decoder=asdf_decoder,
1055
+ tags=[CryonirspTag.intermediate(), CryonirspTag.task_spectral_fit()],
1056
+ decoder=json_decoder,
1026
1057
  )
1027
1058
  )
1028
- del fit_dict["asdf_library"]
1029
- del fit_dict["history"]
1030
- return fit_dict
1031
1059
 
1032
1060
  def update_spectral_headers(self, header: fits.Header):
1033
1061
  """Update spectral headers after spectral correction."""
1034
- for key, value in self.spectral_fit_results.items():
1035
- # update the headers
1036
- header[key] = value
1037
-
1038
- header["CTYPE1"] = "AWAV-GRA"
1039
- header["CUNIT1"] = "nm"
1040
- header["CTYPE1A"] = "AWAV-GRA"
1041
- header["CUNIT1A"] = "nm"
1062
+ header.update(self.spectral_fit_results)