dkist-processing-cryonirsp 1.3.4__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.

Potentially problematic release.


This version of dkist-processing-cryonirsp might be problematic. Click here for more details.

Files changed (111) hide show
  1. changelog/.gitempty +0 -0
  2. dkist_processing_cryonirsp/__init__.py +11 -0
  3. dkist_processing_cryonirsp/config.py +12 -0
  4. dkist_processing_cryonirsp/models/__init__.py +1 -0
  5. dkist_processing_cryonirsp/models/constants.py +248 -0
  6. dkist_processing_cryonirsp/models/exposure_conditions.py +26 -0
  7. dkist_processing_cryonirsp/models/parameters.py +296 -0
  8. dkist_processing_cryonirsp/models/tags.py +168 -0
  9. dkist_processing_cryonirsp/models/task_name.py +14 -0
  10. dkist_processing_cryonirsp/parsers/__init__.py +1 -0
  11. dkist_processing_cryonirsp/parsers/cryonirsp_l0_fits_access.py +111 -0
  12. dkist_processing_cryonirsp/parsers/cryonirsp_l1_fits_access.py +30 -0
  13. dkist_processing_cryonirsp/parsers/exposure_conditions.py +163 -0
  14. dkist_processing_cryonirsp/parsers/map_repeats.py +40 -0
  15. dkist_processing_cryonirsp/parsers/measurements.py +55 -0
  16. dkist_processing_cryonirsp/parsers/modstates.py +31 -0
  17. dkist_processing_cryonirsp/parsers/optical_density_filters.py +40 -0
  18. dkist_processing_cryonirsp/parsers/polarimetric_check.py +120 -0
  19. dkist_processing_cryonirsp/parsers/scan_step.py +412 -0
  20. dkist_processing_cryonirsp/parsers/time.py +80 -0
  21. dkist_processing_cryonirsp/parsers/wavelength.py +26 -0
  22. dkist_processing_cryonirsp/tasks/__init__.py +19 -0
  23. dkist_processing_cryonirsp/tasks/assemble_movie.py +202 -0
  24. dkist_processing_cryonirsp/tasks/bad_pixel_map.py +96 -0
  25. dkist_processing_cryonirsp/tasks/beam_boundaries_base.py +279 -0
  26. dkist_processing_cryonirsp/tasks/ci_beam_boundaries.py +55 -0
  27. dkist_processing_cryonirsp/tasks/ci_science.py +169 -0
  28. dkist_processing_cryonirsp/tasks/cryonirsp_base.py +67 -0
  29. dkist_processing_cryonirsp/tasks/dark.py +98 -0
  30. dkist_processing_cryonirsp/tasks/gain.py +251 -0
  31. dkist_processing_cryonirsp/tasks/instrument_polarization.py +447 -0
  32. dkist_processing_cryonirsp/tasks/l1_output_data.py +44 -0
  33. dkist_processing_cryonirsp/tasks/linearity_correction.py +582 -0
  34. dkist_processing_cryonirsp/tasks/make_movie_frames.py +302 -0
  35. dkist_processing_cryonirsp/tasks/mixin/__init__.py +1 -0
  36. dkist_processing_cryonirsp/tasks/mixin/beam_access.py +52 -0
  37. dkist_processing_cryonirsp/tasks/mixin/corrections.py +177 -0
  38. dkist_processing_cryonirsp/tasks/mixin/intermediate_frame.py +193 -0
  39. dkist_processing_cryonirsp/tasks/mixin/linearized_frame.py +309 -0
  40. dkist_processing_cryonirsp/tasks/mixin/shift_measurements.py +297 -0
  41. dkist_processing_cryonirsp/tasks/parse.py +281 -0
  42. dkist_processing_cryonirsp/tasks/quality_metrics.py +271 -0
  43. dkist_processing_cryonirsp/tasks/science_base.py +511 -0
  44. dkist_processing_cryonirsp/tasks/sp_beam_boundaries.py +270 -0
  45. dkist_processing_cryonirsp/tasks/sp_dispersion_axis_correction.py +484 -0
  46. dkist_processing_cryonirsp/tasks/sp_geometric.py +585 -0
  47. dkist_processing_cryonirsp/tasks/sp_science.py +299 -0
  48. dkist_processing_cryonirsp/tasks/sp_solar_gain.py +475 -0
  49. dkist_processing_cryonirsp/tasks/trial_output_data.py +61 -0
  50. dkist_processing_cryonirsp/tasks/write_l1.py +1033 -0
  51. dkist_processing_cryonirsp/tests/__init__.py +1 -0
  52. dkist_processing_cryonirsp/tests/conftest.py +456 -0
  53. dkist_processing_cryonirsp/tests/header_models.py +592 -0
  54. dkist_processing_cryonirsp/tests/local_trial_workflows/__init__.py +0 -0
  55. dkist_processing_cryonirsp/tests/local_trial_workflows/l0_cals_only.py +541 -0
  56. dkist_processing_cryonirsp/tests/local_trial_workflows/l0_to_l1.py +615 -0
  57. dkist_processing_cryonirsp/tests/local_trial_workflows/linearize_only.py +96 -0
  58. dkist_processing_cryonirsp/tests/local_trial_workflows/local_trial_helpers.py +592 -0
  59. dkist_processing_cryonirsp/tests/test_assemble_movie.py +144 -0
  60. dkist_processing_cryonirsp/tests/test_assemble_qualilty.py +517 -0
  61. dkist_processing_cryonirsp/tests/test_bad_pixel_maps.py +115 -0
  62. dkist_processing_cryonirsp/tests/test_ci_beam_boundaries.py +106 -0
  63. dkist_processing_cryonirsp/tests/test_ci_science.py +355 -0
  64. dkist_processing_cryonirsp/tests/test_corrections.py +126 -0
  65. dkist_processing_cryonirsp/tests/test_cryo_base.py +202 -0
  66. dkist_processing_cryonirsp/tests/test_cryo_constants.py +76 -0
  67. dkist_processing_cryonirsp/tests/test_dark.py +287 -0
  68. dkist_processing_cryonirsp/tests/test_gain.py +278 -0
  69. dkist_processing_cryonirsp/tests/test_instrument_polarization.py +531 -0
  70. dkist_processing_cryonirsp/tests/test_linearity_correction.py +245 -0
  71. dkist_processing_cryonirsp/tests/test_make_movie_frames.py +111 -0
  72. dkist_processing_cryonirsp/tests/test_parameters.py +266 -0
  73. dkist_processing_cryonirsp/tests/test_parse.py +1439 -0
  74. dkist_processing_cryonirsp/tests/test_quality.py +203 -0
  75. dkist_processing_cryonirsp/tests/test_sp_beam_boundaries.py +112 -0
  76. dkist_processing_cryonirsp/tests/test_sp_dispersion_axis_correction.py +155 -0
  77. dkist_processing_cryonirsp/tests/test_sp_geometric.py +319 -0
  78. dkist_processing_cryonirsp/tests/test_sp_make_movie_frames.py +121 -0
  79. dkist_processing_cryonirsp/tests/test_sp_science.py +483 -0
  80. dkist_processing_cryonirsp/tests/test_sp_solar.py +198 -0
  81. dkist_processing_cryonirsp/tests/test_trial_create_quality_report.py +79 -0
  82. dkist_processing_cryonirsp/tests/test_trial_output_data.py +251 -0
  83. dkist_processing_cryonirsp/tests/test_workflows.py +9 -0
  84. dkist_processing_cryonirsp/tests/test_write_l1.py +436 -0
  85. dkist_processing_cryonirsp/workflows/__init__.py +2 -0
  86. dkist_processing_cryonirsp/workflows/ci_l0_processing.py +77 -0
  87. dkist_processing_cryonirsp/workflows/sp_l0_processing.py +84 -0
  88. dkist_processing_cryonirsp/workflows/trial_workflows.py +190 -0
  89. dkist_processing_cryonirsp-1.3.4.dist-info/METADATA +194 -0
  90. dkist_processing_cryonirsp-1.3.4.dist-info/RECORD +111 -0
  91. dkist_processing_cryonirsp-1.3.4.dist-info/WHEEL +5 -0
  92. dkist_processing_cryonirsp-1.3.4.dist-info/top_level.txt +4 -0
  93. docs/Makefile +134 -0
  94. docs/bad_pixel_calibration.rst +47 -0
  95. docs/beam_angle_calculation.rst +53 -0
  96. docs/beam_boundary_computation.rst +88 -0
  97. docs/changelog.rst +7 -0
  98. docs/ci_science_calibration.rst +33 -0
  99. docs/conf.py +52 -0
  100. docs/index.rst +21 -0
  101. docs/l0_to_l1_cryonirsp_ci-full-trial.rst +10 -0
  102. docs/l0_to_l1_cryonirsp_ci.rst +10 -0
  103. docs/l0_to_l1_cryonirsp_sp-full-trial.rst +10 -0
  104. docs/l0_to_l1_cryonirsp_sp.rst +10 -0
  105. docs/linearization.rst +43 -0
  106. docs/make.bat +170 -0
  107. docs/requirements.txt +1 -0
  108. docs/requirements_table.rst +8 -0
  109. docs/scientific_changelog.rst +10 -0
  110. docs/sp_science_calibration.rst +59 -0
  111. licenses/LICENSE.rst +11 -0
@@ -0,0 +1,1033 @@
1
+ """Cryonirsp write L1 task."""
2
+ from abc import ABC
3
+ from abc import abstractmethod
4
+ from functools import cached_property
5
+ from typing import Callable
6
+ from typing import Literal
7
+
8
+ import astropy.units as u
9
+ import numpy as np
10
+ from astropy.coordinates.builtin_frames.altaz import AltAz
11
+ from astropy.coordinates.sky_coordinate import SkyCoord
12
+ from astropy.io import fits
13
+ from astropy.time.core import Time
14
+ from dkist_processing_common.codecs.asdf import asdf_decoder
15
+ from dkist_processing_common.models.wavelength import WavelengthRange
16
+ from dkist_processing_common.tasks import WriteL1Frame
17
+ from dkist_processing_common.tasks.mixin.input_dataset import InputDatasetMixin
18
+ from sunpy.coordinates import GeocentricEarthEquatorial
19
+ from sunpy.coordinates import Helioprojective
20
+
21
+ from dkist_processing_cryonirsp.models.constants import CryonirspConstants
22
+ from dkist_processing_cryonirsp.models.parameters import CryonirspParameters
23
+ from dkist_processing_cryonirsp.models.tags import CryonirspTag
24
+
25
+ __all__ = ["CIWriteL1Frame", "SPWriteL1Frame"]
26
+
27
+
28
+ class CryonirspWriteL1Frame(WriteL1Frame, ABC, InputDatasetMixin):
29
+ """
30
+ Task class for writing out calibrated l1 CryoNIRSP frames.
31
+
32
+ Parameters
33
+ ----------
34
+ recipe_run_id : int
35
+ id of the recipe run used to identify the workflow run this task is part of
36
+ workflow_name : str
37
+ name of the workflow to which this instance of the task belongs
38
+ workflow_version : str
39
+ version of the workflow to which this instance of the task belongs
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ recipe_run_id: int,
45
+ workflow_name: str,
46
+ workflow_version: str,
47
+ ):
48
+ super().__init__(
49
+ recipe_run_id=recipe_run_id,
50
+ workflow_name=workflow_name,
51
+ workflow_version=workflow_version,
52
+ )
53
+ self.parameters = CryonirspParameters(
54
+ self.input_dataset_parameters,
55
+ obs_ip_start_time=self.constants.obs_ip_start_time,
56
+ wavelength=self.constants.wavelength,
57
+ arm_id=self.constants.arm_id,
58
+ )
59
+
60
+ @property
61
+ def constants_model_class(self):
62
+ """Get Cryonirsp pipeline constants."""
63
+ return CryonirspConstants
64
+
65
+ def add_dataset_headers(
66
+ self, header: fits.Header, stokes: Literal["I", "Q", "U", "V"]
67
+ ) -> fits.Header:
68
+ """
69
+ Add the Cryonirsp specific dataset headers to L1 FITS files.
70
+
71
+ Parameters
72
+ ----------
73
+ header : fits.Header
74
+ calibrated data header
75
+
76
+ stokes :
77
+ stokes parameter
78
+
79
+ Returns
80
+ -------
81
+ fits.Header
82
+ calibrated header with correctly written l1 headers
83
+ """
84
+ first_axis = 1
85
+ next_axis = self.add_first_axis(header, axis_num=first_axis)
86
+ next_axis = self.add_second_axis(header, axis_num=next_axis)
87
+ multiple_measurements = self.constants.num_meas > 1
88
+ if multiple_measurements:
89
+ next_axis = self.add_measurement_axis(header, next_axis)
90
+ next_axis = self.add_scan_step_axis(header, axis_num=next_axis)
91
+ if self.constants.num_map_scans > 1:
92
+ next_axis = self.add_map_scan_axis(header, axis_num=next_axis)
93
+ if self.constants.correct_for_polarization:
94
+ next_axis = self.add_stokes_axis(header, stokes=stokes, axis_num=next_axis)
95
+ self.add_wavelength_headers(header)
96
+ last_axis = next_axis - 1
97
+ self.add_common_headers(header, num_axes=last_axis)
98
+ self.flip_spectral_axis(header)
99
+ boresight_coordinates = self.get_boresight_coords(header)
100
+ self.correct_spatial_wcs_info(header, boresight_coordinates)
101
+ self.update_spectral_headers(header)
102
+
103
+ return header
104
+
105
+ @property
106
+ @abstractmethod
107
+ def latitude_pixel_name(self) -> str:
108
+ """Return the descriptive name for the longitudinal axis."""
109
+ pass
110
+
111
+ @property
112
+ @abstractmethod
113
+ def add_first_axis(self) -> Callable:
114
+ """Return the add method for the first axis."""
115
+ pass
116
+
117
+ @property
118
+ @abstractmethod
119
+ def add_second_axis(self) -> Callable:
120
+ """Return the add method for the second axis."""
121
+ pass
122
+
123
+ def flip_spectral_axis(self, header: fits.Header):
124
+ """Adjust header values corresponding to axis flip."""
125
+ pass
126
+
127
+ def add_helioprojective_latitude_axis(self, header: fits.Header, axis_num: int) -> int:
128
+ """Add header keys for the spatial helioprojective latitude axis."""
129
+ header[f"DNAXIS{axis_num}"] = header[f"NAXIS{axis_num}"]
130
+ header[f"DTYPE{axis_num}"] = "SPATIAL"
131
+ header[f"DPNAME{axis_num}"] = self.latitude_pixel_name
132
+ header[f"DWNAME{axis_num}"] = "helioprojective latitude"
133
+ header[f"DUNIT{axis_num}"] = header[f"CUNIT{axis_num}"]
134
+ next_axis = axis_num + 1
135
+ return next_axis
136
+
137
+ def add_measurement_axis(self, header: fits.Header, axis_num: int) -> int:
138
+ """Add header keys related to multiple measurements."""
139
+ header[f"DNAXIS{axis_num}"] = self.constants.num_meas
140
+ header[f"DTYPE{axis_num}"] = "TEMPORAL"
141
+ header[f"DPNAME{axis_num}"] = "measurement number"
142
+ header[f"DWNAME{axis_num}"] = "time"
143
+ header[f"DUNIT{axis_num}"] = "s"
144
+ # DINDEX and CNCMEAS are both one-based
145
+ header[f"DINDEX{axis_num}"] = header["CNCMEAS"]
146
+ next_axis = axis_num + 1
147
+ return next_axis
148
+
149
+ @abstractmethod
150
+ def add_scan_step_axis(self, header: fits.Header, axis_num: int) -> int:
151
+ pass
152
+
153
+ def add_map_scan_axis(self, header: fits.Header, axis_num: int) -> int:
154
+ """Add header keys for the temporal map scan axis."""
155
+ header["CNNMAPS"] = self.constants.num_map_scans
156
+ header[f"DNAXIS{axis_num}"] = self.constants.num_map_scans
157
+ header[f"DTYPE{axis_num}"] = "TEMPORAL"
158
+ header[f"DPNAME{axis_num}"] = "map scan number"
159
+ header[f"DWNAME{axis_num}"] = "time"
160
+ header[f"DUNIT{axis_num}"] = "s"
161
+ # Temporal position in dataset
162
+ # DINDEX and CNMAP are both one-based
163
+ header[f"DINDEX{axis_num}"] = header["CNMAP"]
164
+ next_axis = axis_num + 1
165
+ return next_axis
166
+
167
+ def add_stokes_axis(
168
+ self, header: fits.Header, stokes: Literal["I", "Q", "U", "V"], axis_num: int
169
+ ) -> int:
170
+ """Add header keys for the stokes polarization axis."""
171
+ header[f"DNAXIS{axis_num}"] = 4 # I, Q, U, V
172
+ header[f"DTYPE{axis_num}"] = "STOKES"
173
+ header[f"DPNAME{axis_num}"] = "polarization state"
174
+ header[f"DWNAME{axis_num}"] = "polarization state"
175
+ header[f"DUNIT{axis_num}"] = ""
176
+ # Stokes position in dataset - stokes axis goes from 1-4
177
+ header[f"DINDEX{axis_num}"] = self.constants.stokes_params.index(stokes.upper()) + 1
178
+ next_axis = axis_num + 1
179
+ return next_axis
180
+
181
+ def add_wavelength_headers(self, header: fits.Header) -> None:
182
+ """Add header keys related to the observing wavelength."""
183
+ # The wavemin and wavemax assume that all frames in a dataset have identical wavelength axes
184
+ header["WAVEUNIT"] = -9 # nanometers
185
+ header["WAVEREF"] = "Air"
186
+ wavelength_range = self.get_wavelength_range(header)
187
+ header["WAVEMIN"] = wavelength_range.min.to(u.nm).value
188
+ header["WAVEMAX"] = wavelength_range.max.to(u.nm).value
189
+
190
+ @staticmethod
191
+ def add_common_headers(header: fits.Header, num_axes: int) -> None:
192
+ """Add header keys that are common to both SP and CI."""
193
+ header["DNAXIS"] = num_axes
194
+ header["DAAXES"] = 2 # Spatial, spatial
195
+ header["DEAXES"] = num_axes - 2 # Total - detector axes
196
+ header["LEVEL"] = 1
197
+ # Binning headers
198
+ header["NBIN1"] = 1
199
+ header["NBIN2"] = 1
200
+ header["NBIN3"] = 1
201
+ header["NBIN"] = header["NBIN1"] * header["NBIN2"] * header["NBIN3"]
202
+
203
+ def calculate_date_end(self, header: fits.Header) -> str:
204
+ """
205
+ In CryoNIRSP, the instrument specific DATE-END keyword is calculated during science calibration.
206
+
207
+ Check that it exists.
208
+
209
+ Parameters
210
+ ----------
211
+ header
212
+ The input fits header
213
+ """
214
+ try:
215
+ return header["DATE-END"]
216
+ except KeyError as e:
217
+ raise KeyError(
218
+ f"The 'DATE-END' keyword was not found. "
219
+ f"Was supposed to be inserted during science calibration."
220
+ ) from e
221
+
222
+ def l1_filename(self, header: fits.Header, stokes: Literal["I", "Q", "U", "V"]):
223
+ """
224
+ Use a FITS header to derive its filename in the following format.
225
+
226
+ instrument_arm_datetime_wavelength__stokes_datasetid_L1.fits.
227
+
228
+ This is done by taking the base filename and changing the instrument name to include the arm.
229
+
230
+ Example
231
+ -------
232
+ "CRYO-NIRSP_CI_2020_03_13T00_00_00_000_01080000_Q_DATID_L1.fits"
233
+
234
+ Parameters
235
+ ----------
236
+ header
237
+ The input fits header
238
+ stokes
239
+ The stokes parameter
240
+
241
+ Returns
242
+ -------
243
+ The L1 filename including the arm ID
244
+ """
245
+ base_l1_filename = super().l1_filename(header, stokes)
246
+ return base_l1_filename.replace(
247
+ self.constants.instrument, f"{self.constants.instrument}_{self.constants.arm_id}"
248
+ )
249
+
250
+ @abstractmethod
251
+ def get_boresight_coords(self, header: fits.Header) -> tuple[float, float]:
252
+ """Get boresight coordinates to use in spatial correction."""
253
+ pass
254
+
255
+ def correct_spatial_wcs_info(
256
+ self, header: fits.Header, boresight_coordinates: tuple[float, float]
257
+ ) -> None:
258
+ """
259
+ Correct spatial WCS info.
260
+
261
+ CryoNIRSP is not exactly where it thinks it is in space, resulting in the spatial coordinates being off.
262
+
263
+ Steps:
264
+ 1. Get target coordinates from boresight coordinates.
265
+ 2. Find the solar orientation at the time of observation by finding the angle
266
+ between the zenith and solar north at the center boresight pointing.
267
+ 3. Find the instrument slit orientation at the time of observation by finding the
268
+ sun center azimuth and elevation, and calculate the slit orientation angle using those values.
269
+ 4. Correct and update the helioprojective headers.
270
+ 5. Correct and update the equatorial headers.
271
+
272
+ Returns
273
+ -------
274
+ None
275
+ """
276
+ t0 = Time(header["DATE-BEG"])
277
+ sky_coordinates = SkyCoord(
278
+ boresight_coordinates[0] * u.arcsec,
279
+ boresight_coordinates[1] * u.arcsec,
280
+ obstime=t0,
281
+ observer=self.location_of_dkist.get_itrs(t0),
282
+ frame="helioprojective",
283
+ )
284
+
285
+ frame_altaz = AltAz(obstime=t0, location=self.location_of_dkist)
286
+
287
+ # Find angle between zenith and solar north at the center boresight pointing
288
+ solar_orientation_angle = self.get_solar_orientation_angle(
289
+ boresight_coordinates, t0, sky_coordinates, frame_altaz
290
+ )
291
+
292
+ # get sun center azimuth and elevation and calculate the slit orientation angle using that value
293
+ slit_orientation_angle = self.get_slit_orientation_angle(
294
+ header, sky_coordinates, frame_altaz, solar_orientation_angle
295
+ )
296
+
297
+ # correct coordinates
298
+ (
299
+ observed_pix_x_rotated,
300
+ observed_pix_y_rotated,
301
+ cdelt_updated,
302
+ pix_off_updated,
303
+ crpix_updated,
304
+ pcij_updated,
305
+ ) = self.correct_helioprojective_coords(header, slit_orientation_angle.value)
306
+ self.update_helioprojective_headers(
307
+ header, pcij_updated, crpix_updated, cdelt_updated.value
308
+ )
309
+
310
+ x, y, wci_updated_eq, crval_eq = self.get_arm_specific_coords(
311
+ header, t0, observed_pix_x_rotated, observed_pix_y_rotated
312
+ )
313
+
314
+ pcij_update_eq, cdelt_eq = self.correct_equatorial_coords(
315
+ cdelt_updated, pix_off_updated, crval_eq, wci_updated_eq
316
+ )
317
+ self.update_equatorial_headers(
318
+ header, pcij_update_eq, crpix_updated, cdelt_eq, slit_orientation_angle.value
319
+ )
320
+
321
+ def get_solar_orientation_angle(
322
+ self,
323
+ boresight_coordinates: tuple[float, float],
324
+ t0: Time,
325
+ sky_coordinates: SkyCoord,
326
+ frame_altaz: AltAz,
327
+ ) -> u.Quantity:
328
+ """
329
+ Get orientation of sun at time of observation.
330
+
331
+ 1. Given target coordinates, create a vector pointing 1 arcsec to solar north and transform that into the
332
+ altaz frame.
333
+ 2. Create a second vector pointing 1 arcsec to zenith.
334
+ 3. Project and normalize the zenith vector and the target vector onto sky.
335
+ 4. Finally, calculate the angle between the two vectors to get the solar orientation.
336
+
337
+ Returns
338
+ -------
339
+ float
340
+ The solar orientation at the time of the observation.
341
+ """
342
+ # Vector pointing 1 arcsec to solar north
343
+ sky_coord_north = SkyCoord(
344
+ boresight_coordinates[0] * u.arcsec,
345
+ (boresight_coordinates[1] + 1) * u.arcsec,
346
+ obstime=t0,
347
+ observer=self.location_of_dkist.get_itrs(t0),
348
+ frame="helioprojective",
349
+ )
350
+
351
+ # transform to altaz frame and get the third coordinate of a point towards the zenith (i.e. alitude of boresight + a small angle)
352
+ with Helioprojective.assume_spherical_screen(sky_coordinates.observer):
353
+ sky_altaz = sky_coordinates.transform_to(frame_altaz)
354
+ sky_coord_north_altaz = sky_coord_north.transform_to(frame_altaz)
355
+ zenith_altaz = SkyCoord(
356
+ sky_altaz.az,
357
+ sky_altaz.alt + 1 * u.arcsec,
358
+ sky_altaz.distance,
359
+ frame=frame_altaz,
360
+ )
361
+
362
+ # Use cross products to obtain the sky projections of the two vectors (rotated by 90 deg)
363
+ sky_normal = sky_altaz.data.to_cartesian()
364
+ sky_coord_north_normal = sky_coord_north_altaz.data.to_cartesian()
365
+ zenith_normal = zenith_altaz.data.to_cartesian()
366
+
367
+ # Project zenith direction and direction to observed point into sky
368
+ zenith_in_sky = zenith_normal.cross(sky_normal)
369
+ sky_coord_north_in_sky = sky_coord_north_normal.cross(sky_normal)
370
+
371
+ # Normalize directional vectors
372
+ sky_normal /= sky_normal.norm()
373
+ sky_coord_north_normal /= sky_coord_north_normal.norm()
374
+ sky_coord_north_in_sky /= sky_coord_north_in_sky.norm()
375
+ zenith_in_sky /= zenith_in_sky.norm()
376
+
377
+ # Calculate the signed angle between the two projected vectors (angle between zenith and target)
378
+ cos_theta = sky_coord_north_in_sky.dot(zenith_in_sky)
379
+ sin_theta = sky_coord_north_in_sky.cross(zenith_in_sky).dot(sky_normal)
380
+
381
+ solar_orientation_angle = np.rad2deg(np.arctan2(sin_theta, cos_theta))
382
+
383
+ return solar_orientation_angle
384
+
385
+ def get_slit_orientation_angle(
386
+ self,
387
+ header: fits.Header,
388
+ sky_coordinates: SkyCoord,
389
+ frame_altaz: AltAz,
390
+ solar_orientation_angle: float,
391
+ ) -> u.Quantity:
392
+ """Get CryoNIRSP slit orientation measured relative to solar north at time of observation."""
393
+ with Helioprojective.assume_spherical_screen(sky_coordinates.observer):
394
+ sc_alt = sky_coordinates.transform_to(frame_altaz)
395
+ coude_minus_azimuth_elevation = header["TTBLANGL"] * u.deg - (
396
+ (sc_alt.az.deg - sc_alt.alt.deg) * u.deg
397
+ )
398
+ cryo_instrument_alignment_angle = self.parameters.cryo_instrument_alignment_angle
399
+ slit_orientation_angle = (
400
+ coude_minus_azimuth_elevation
401
+ + cryo_instrument_alignment_angle
402
+ + solar_orientation_angle
403
+ )
404
+
405
+ return slit_orientation_angle
406
+
407
+ @abstractmethod
408
+ def correct_helioprojective_coords(
409
+ self, header: fits.header, slit_orientation_angle: float
410
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
411
+ """Correct helioprojective coordinates."""
412
+ pass
413
+
414
+ @abstractmethod
415
+ def update_helioprojective_headers(
416
+ self,
417
+ header: fits.header,
418
+ pcij_updated: np.ndarray,
419
+ crpix_updated: np.ndarray,
420
+ cdelt_updated: np.ndarray,
421
+ ) -> None:
422
+ """Update helioprojective headers."""
423
+ pass
424
+
425
+ @abstractmethod
426
+ def get_arm_specific_coords(
427
+ self,
428
+ header: fits.Header,
429
+ t0: Time,
430
+ observed_pix_x_rotated: np.ndarray,
431
+ observed_pix_y_rotated: np.ndarray,
432
+ ) -> tuple[float, float, np.ndarray, np.ndarray]:
433
+ """Get SP or CI specific coordinates to use in equatorial coordinate correction."""
434
+ pass
435
+
436
+ def correct_equatorial_coords(
437
+ self,
438
+ cdelt_updated: np.ndarray,
439
+ pix_off_updated: np.ndarray,
440
+ crval_eq: np.ndarray,
441
+ wci_updated_eq: np.ndarray,
442
+ ) -> tuple[np.ndarray, np.ndarray]:
443
+ """Correct equatorial coordinates for SP or CI arm."""
444
+ cdelt_eq = cdelt_updated.to(u.deg / u.pixel).value
445
+ pcij_updated_eq = (
446
+ (wci_updated_eq - crval_eq[:, None])
447
+ @ np.linalg.pinv(pix_off_updated[:, :])
448
+ / cdelt_eq[:, None]
449
+ )
450
+
451
+ return pcij_updated_eq, cdelt_eq
452
+
453
+ @abstractmethod
454
+ def update_equatorial_headers(
455
+ self,
456
+ header: fits.Header,
457
+ pcij_updateEq: np.ndarray,
458
+ crpix_updated: np.ndarray,
459
+ cdelt_eq: np.ndarray,
460
+ slit_orientation_angle: float,
461
+ ) -> None:
462
+ """Update equatorial headers."""
463
+ pass
464
+
465
+ def update_spectral_headers(self, header: fits.Header):
466
+ """Update spectral headers after spectral correction."""
467
+ pass
468
+
469
+
470
+ class CIWriteL1Frame(CryonirspWriteL1Frame):
471
+ """
472
+ Task class for writing out calibrated L1 CryoNIRSP-CI frames.
473
+
474
+ Parameters
475
+ ----------
476
+ recipe_run_id : int
477
+ id of the recipe run used to identify the workflow run this task is part of
478
+ workflow_name : str
479
+ name of the workflow to which this instance of the task belongs
480
+ workflow_version : str
481
+ version of the workflow to which this instance of the task belongs
482
+ """
483
+
484
+ @property
485
+ def latitude_pixel_name(self) -> str:
486
+ """CI has latitude along the xaxis of the detector."""
487
+ return "detector x axis"
488
+
489
+ @property
490
+ def add_first_axis(self) -> Callable:
491
+ """The first axis in the data is helioprojective longitude."""
492
+ return self.add_helioprojective_longitude_axis
493
+
494
+ @property
495
+ def add_second_axis(self) -> Callable:
496
+ """The first axis in the data is helioprojective latitude."""
497
+ return self.add_helioprojective_latitude_axis
498
+
499
+ @staticmethod
500
+ def add_helioprojective_longitude_axis(header: fits.Header, axis_num: int) -> int:
501
+ """Add header keys for the spatial helioprojective longitude axis."""
502
+ header[f"DNAXIS{axis_num}"] = header[f"NAXIS{axis_num}"]
503
+ header[f"DTYPE{axis_num}"] = "SPATIAL"
504
+ header[f"DWNAME{axis_num}"] = "helioprojective longitude"
505
+ header[f"DUNIT{axis_num}"] = header[f"CUNIT{axis_num}"]
506
+ header[f"DPNAME{axis_num}"] = "detector y axis"
507
+ next_axis = axis_num + 1
508
+ return next_axis
509
+
510
+ def add_scan_step_axis(self, header: fits.Header, axis_num: int) -> int:
511
+ """Add header keys for the scan step axis."""
512
+ header[f"DNAXIS{axis_num}"] = self.constants.num_scan_steps
513
+ header[f"DTYPE{axis_num}"] = "TEMPORAL"
514
+ header[f"DPNAME{axis_num}"] = "scan step number"
515
+ header[f"DWNAME{axis_num}"] = "time"
516
+ header[f"DUNIT{axis_num}"] = "s"
517
+ # DINDEX and CNCURSCN are both one-based
518
+ header[f"DINDEX{axis_num}"] = header["CNCURSCN"]
519
+ next_axis = axis_num + 1
520
+ return next_axis
521
+
522
+ def flip_spectral_axis(self, header: fits.Header):
523
+ """Adjust header values corresponding to axis flip."""
524
+ pass
525
+
526
+ def get_wavelength_range(self, header: fits.Header) -> WavelengthRange:
527
+ """
528
+ Return the wavelength range of this frame.
529
+
530
+ Range is the wavelengths at the edges of the filter full width half maximum.
531
+ """
532
+ filter_central_wavelength = header["CNCENWAV"] * u.nm
533
+ filter_fwhm = header["CNFWHM"] * u.nm
534
+ return WavelengthRange(
535
+ min=filter_central_wavelength - (filter_fwhm / 2),
536
+ max=filter_central_wavelength + (filter_fwhm / 2),
537
+ )
538
+
539
+ def get_boresight_coords(self, header: fits.Header) -> tuple[float, float]:
540
+ """Get boresight coordinates to use in CI spatial correction."""
541
+ boresight_coordinates = header["CRVAL1"], header["CRVAL2"]
542
+
543
+ return boresight_coordinates
544
+
545
+ def correct_helioprojective_coords(
546
+ self, header: fits.Header, slit_orientation_angle: float
547
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
548
+ """
549
+ Correct CI helioprojective coordinates.
550
+
551
+ 1. Update CDELT to be static and squared over data.
552
+ 2. Update CRPIX to change according to the offset from the boresight coordinates.
553
+ 3. Update the WCI info:
554
+ a. Perform a slit center scale correction.
555
+ b. Get the angular values of the observed pixels in the frame of the instrument.
556
+ c. Rotate the angular values into the solar frame by accounting for the telescope geometry.
557
+ 4. Update the PCij values.
558
+ """
559
+ # update cdelt to be static over the data and squared
560
+ ##################################################################################
561
+ spatial_scale_along_slit = self.parameters.ci_spatial_scale_along_slit
562
+ cdelt_updated = np.stack((spatial_scale_along_slit, spatial_scale_along_slit))
563
+ ##################################################################################
564
+
565
+ # update crpix to change according to offset from boresight coordinates
566
+ ##################################################################################
567
+ mirror_scan_recalibration = (
568
+ self.parameters.mirror_scan_recalibration_constant
569
+ ) # recalibration to the scale of the field steering mirror scanning.
570
+ # Instrument controller applies a 0.5 arcsec step, but in reality, it is 0.466 on sky
571
+ header["CNM1BOFF"] = 8.0
572
+ header["CNM1OFF"] = -2.75
573
+ crpix1_new = (
574
+ 1108
575
+ - (-(header["CNM1POS"] - header["CNM1OFF"]))
576
+ * mirror_scan_recalibration
577
+ / cdelt_updated.value[0]
578
+ )
579
+ crpix2_new = (
580
+ 1010
581
+ - (-(header["CNM1BPOS"] - header["CNM1BOFF"]))
582
+ * mirror_scan_recalibration
583
+ / cdelt_updated.value[1]
584
+ )
585
+ crpix_updated = np.array([crpix1_new, crpix2_new])
586
+
587
+ xpix = (
588
+ np.hstack(
589
+ (
590
+ np.linspace(0, header["NAXIS1"], header["NAXIS1"]),
591
+ np.linspace(header["NAXIS1"], 0, header["NAXIS1"]),
592
+ )
593
+ )
594
+ + 1
595
+ ) ## plus 1 due to 1-indexed standard
596
+ ypix = (
597
+ np.hstack(
598
+ (
599
+ np.linspace(0, header["NAXIS2"], header["NAXIS1"]),
600
+ np.linspace(0, header["NAXIS2"], header["NAXIS1"]),
601
+ )
602
+ )
603
+ + 1
604
+ ) ## plus 1 due to 1-indexed standard
605
+ pix = np.stack((xpix, ypix))
606
+
607
+ pix_off_updated = pix - crpix_updated[:, None]
608
+ #################################################################################
609
+
610
+ # update WCI information
611
+ #################################################################################
612
+ slit_center_x = (
613
+ (-(header["CNM1POS"] - header["CNM1OFF"]))
614
+ ) * mirror_scan_recalibration # SCALE CORRECTION
615
+ slit_center_y = (
616
+ -(header["CNM1BPOS"] - header["CNM1BOFF"])
617
+ ) * mirror_scan_recalibration # SCALE CORRECTION.
618
+
619
+ # angular values of observed pixels in frame of instrument
620
+ observed_pix_y = slit_center_y + (ypix - 1010) * cdelt_updated.value[1]
621
+ observed_pix_x = slit_center_x + (xpix - 1108) * cdelt_updated.value[0]
622
+
623
+ # angular values of observed pixels rotated into the solar frame by accounting for the telescope geometry
624
+ theta = np.deg2rad(slit_orientation_angle)
625
+ observed_pix_x_rotated = np.cos(theta) * observed_pix_x - np.sin(theta) * observed_pix_y
626
+ observed_pix_y_rotated = np.sin(theta) * observed_pix_x + np.cos(theta) * observed_pix_y
627
+ observed_pix_x_rotated += header["CRVAL1"]
628
+ observed_pix_y_rotated += header["CRVAL2"]
629
+ wci_updated = np.stack((observed_pix_x_rotated, observed_pix_y_rotated))
630
+ ##################################################################################
631
+
632
+ # correct pcij values
633
+ ##################################################################################
634
+ crval = np.array([header["CRVAL1"], header["CRVAL2"]])
635
+ pcij_updated = (
636
+ (wci_updated - crval[:, None])
637
+ @ np.linalg.pinv(pix_off_updated[:, :])
638
+ / cdelt_updated.value[:, None]
639
+ )
640
+ ##################################################################################
641
+
642
+ return (
643
+ observed_pix_x_rotated,
644
+ observed_pix_y_rotated,
645
+ cdelt_updated,
646
+ pix_off_updated,
647
+ crpix_updated,
648
+ pcij_updated,
649
+ )
650
+
651
+ def update_helioprojective_headers(
652
+ self,
653
+ header: fits.Header,
654
+ pcij_updated: np.ndarray,
655
+ crpix_updated: np.ndarray,
656
+ cdelt_updated: np.ndarray,
657
+ ) -> None:
658
+ """Update the helioprojective headers with corrected helioprojective coordinates for CI arm."""
659
+ # UPDATE VALUES FOR HPLT-TAN / HPLN-TAN axes
660
+ header["CNM1BOFF"] = 8.0
661
+ header["CNM1OFF"] = -2.75
662
+ header["PC1_1"] = pcij_updated[0, 0]
663
+ header["PC1_2"] = pcij_updated[0, 1]
664
+ header["PC1_3"] = 0
665
+ header["PC2_1"] = pcij_updated[1, 0]
666
+ header["PC2_2"] = pcij_updated[1, 1]
667
+ header["PC2_3"] = 0
668
+ header["PC3_1"] = 0
669
+ header["PC3_2"] = 0
670
+ header["PC3_3"] = 1
671
+ header["CRPIX1"] = crpix_updated[0]
672
+ header["CRPIX2"] = crpix_updated[1]
673
+ header["CDELT1"] = cdelt_updated[0]
674
+ header["CDELT2"] = cdelt_updated[1]
675
+
676
+ return
677
+
678
+ def get_arm_specific_coords(
679
+ self,
680
+ header: fits.Header,
681
+ t0: Time,
682
+ observed_pix_x_rotated: np.ndarray,
683
+ observed_pix_y_rotated: np.ndarray,
684
+ ) -> tuple[float, float, np.ndarray, np.ndarray]:
685
+ """Get CI specific coordinates to use in equatorial coordinate correction."""
686
+ x, y = header["CRVAL1"], header["CRVAL2"]
687
+
688
+ sky_coord = SkyCoord(
689
+ observed_pix_x_rotated * u.arcsec,
690
+ observed_pix_y_rotated * u.arcsec,
691
+ obstime=t0,
692
+ observer=self.location_of_dkist.get_itrs(t0),
693
+ frame="helioprojective",
694
+ )
695
+
696
+ with Helioprojective.assume_spherical_screen(sky_coord.observer):
697
+ sky_coord_earth_equatorial = sky_coord.transform_to(GeocentricEarthEquatorial)
698
+ wci_updated_eq = np.stack(
699
+ (sky_coord_earth_equatorial.lon.value, sky_coord_earth_equatorial.lat.value)
700
+ )
701
+
702
+ sky_coord = SkyCoord(
703
+ x * u.arcsec,
704
+ y * u.arcsec,
705
+ obstime=t0,
706
+ observer=self.location_of_dkist.get_itrs(t0),
707
+ frame="helioprojective",
708
+ )
709
+ with Helioprojective.assume_spherical_screen(sky_coord.observer):
710
+ sky_coord_earth_equatorial = sky_coord.transform_to(GeocentricEarthEquatorial)
711
+ crval_eq = np.stack(
712
+ (sky_coord_earth_equatorial.lon.value, sky_coord_earth_equatorial.lat.value)
713
+ )
714
+
715
+ return x, y, wci_updated_eq, crval_eq
716
+
717
+ def update_equatorial_headers(
718
+ self,
719
+ header: fits.Header,
720
+ pcij_updated_eq: np.ndarray,
721
+ crpix_updated: np.ndarray,
722
+ cdelt_eq: np.ndarray,
723
+ slit_orientation_angle: float,
724
+ ) -> None:
725
+ """Update the equatorial headers with corrected equatorial coordinates for CI arm."""
726
+ header["PC1_1A"] = pcij_updated_eq[0, 0]
727
+ header["PC1_2A"] = pcij_updated_eq[0, 1]
728
+ header["PC1_3A"] = 0
729
+ header["PC2_1A"] = pcij_updated_eq[1, 0]
730
+ header["PC2_2A"] = pcij_updated_eq[1, 1]
731
+ header["PC2_3A"] = 0
732
+ header["PC3_1A"] = 0
733
+ header["PC3_2A"] = 0
734
+ header["PC3_3A"] = 1
735
+ header["CRPIX1A"] = crpix_updated[0]
736
+ header["CRPIX2A"] = crpix_updated[1]
737
+ header["CDELT1A"] = cdelt_eq[0]
738
+ header["CDELT2A"] = cdelt_eq[1]
739
+ header["SLITORI"] = slit_orientation_angle
740
+
741
+ return
742
+
743
+
744
+ class SPWriteL1Frame(CryonirspWriteL1Frame):
745
+ """
746
+ Task class for writing out calibrated L1 CryoNIRSP-SP frames.
747
+
748
+ Parameters
749
+ ----------
750
+ recipe_run_id : int
751
+ id of the recipe run used to identify the workflow run this task is part of
752
+ workflow_name : str
753
+ name of the workflow to which this instance of the task belongs
754
+ workflow_version : str
755
+ version of the workflow to which this instance of the task belongs
756
+ """
757
+
758
+ @property
759
+ def latitude_pixel_name(self) -> str:
760
+ """SP has latitude along the spectrograph slit."""
761
+ return "spatial along slit"
762
+
763
+ @property
764
+ def add_first_axis(self) -> Callable:
765
+ """The first axis in the data is spectral."""
766
+ return self.add_spectral_axis
767
+
768
+ @property
769
+ def add_second_axis(self) -> Callable:
770
+ """The second axis in the data is helioprojective latitude."""
771
+ return self.add_helioprojective_latitude_axis
772
+
773
+ @staticmethod
774
+ def add_spectral_axis(header: fits.Header, axis_num: int) -> int:
775
+ """Add header keys for the spectral dispersion axis."""
776
+ header[f"DNAXIS{axis_num}"] = header[f"NAXIS{axis_num}"]
777
+ header[f"DTYPE{axis_num}"] = "SPECTRAL"
778
+ header[f"DPNAME{axis_num}"] = "dispersion axis"
779
+ header[f"DWNAME{axis_num}"] = "wavelength"
780
+ header[f"DUNIT{axis_num}"] = header[f"CUNIT{axis_num}"]
781
+ next_axis = axis_num + 1
782
+ return next_axis
783
+
784
+ def flip_spectral_axis(self, header: fits.Header):
785
+ """Adjust header values corresponding to axis flip."""
786
+ # Fix the dispersion value to always be positive (Cryo gives it to us negative, which is wrong)
787
+ header["CDELT1"] = np.abs(header["CDELT1"])
788
+ # re-set the value of the reference pixel after the axis flip
789
+ header["CRPIX1"] = header["NAXIS1"] - header["CRPIX1"]
790
+
791
+ def add_scan_step_axis(self, header: fits.Header, axis_num: int) -> int:
792
+ """Add header keys for the spatial scan step axis."""
793
+ header[f"DNAXIS{axis_num}"] = self.constants.num_scan_steps
794
+ header[f"DTYPE{axis_num}"] = "SPATIAL"
795
+ header[f"DPNAME{axis_num}"] = "scan step number"
796
+ header[f"DWNAME{axis_num}"] = "helioprojective longitude"
797
+ # NB: CUNIT axis number is hard coded here
798
+ header[f"DUNIT{axis_num}"] = header[f"CUNIT3"]
799
+ # DINDEX and CNCURSCN are both one-based
800
+ header[f"DINDEX{axis_num}"] = header["CNCURSCN"]
801
+ next_axis = axis_num + 1
802
+ return next_axis
803
+
804
+ def get_wavelength_range(self, header: fits.Header) -> WavelengthRange:
805
+ """
806
+ Return the wavelength range of this frame.
807
+
808
+ Range is the wavelength values of the pixels at the ends of the wavelength axis.
809
+ """
810
+ axis_types = [
811
+ self.constants.axis_1_type,
812
+ self.constants.axis_2_type,
813
+ self.constants.axis_3_type,
814
+ ]
815
+ wavelength_axis = axis_types.index("AWAV") + 1 # FITS axis numbering is 1-based, not 0
816
+ wavelength_unit = header[f"CUNIT{wavelength_axis}"]
817
+
818
+ minimum = header[f"CRVAL{wavelength_axis}"] - (
819
+ header[f"CRPIX{wavelength_axis}"] * header[f"CDELT{wavelength_axis}"]
820
+ )
821
+ maximum = header[f"CRVAL{wavelength_axis}"] + (
822
+ (header[f"NAXIS{wavelength_axis}"] - header[f"CRPIX{wavelength_axis}"])
823
+ * header[f"CDELT{wavelength_axis}"]
824
+ )
825
+ return WavelengthRange(
826
+ min=u.Quantity(minimum, unit=wavelength_unit),
827
+ max=u.Quantity(maximum, unit=wavelength_unit),
828
+ )
829
+
830
+ def get_boresight_coords(self, header: fits.Header) -> tuple[float, float]:
831
+ """Get boresight coordinates to use in SP spatial correction."""
832
+ boresight_coordinates = header["CRVAL3"], header["CRVAL2"]
833
+
834
+ return boresight_coordinates
835
+
836
+ def correct_helioprojective_coords(
837
+ self, header: fits.Header, slit_orientation_angle: float
838
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
839
+ """
840
+ Correct SP helioprojective coordinates.
841
+
842
+ 1. Update CDELT to be static and squared over data.
843
+ 2. Update CRPIX to change according to the offset from the boresight coordinates.
844
+ 3. Update the WCI info:
845
+ a. Perform a slit center scale correction.
846
+ b. Get the angular values of the observed pixels in the frame of the instrument.
847
+ c. Rotate the angular values into the solar frame by accounting for the telescope geometry.
848
+ 4. Update the PCij values.
849
+ """
850
+ # update cdelt to be static over the data and squared
851
+ ##################################################################################
852
+ pix_along_slit = np.arange(header["NAXIS2"])
853
+ pix = (
854
+ np.stack((pix_along_slit, 0 * pix_along_slit)) + 1
855
+ ) # FITS axis numbering is 1-based, not 0
856
+ spatial_scale_along_slit = self.parameters.sp_spatial_scale_along_slit
857
+ cdelt_updated = np.stack((spatial_scale_along_slit, spatial_scale_along_slit))
858
+ ##################################################################################
859
+
860
+ # update crpix to change according to offset from boresight coordinates
861
+ ##################################################################################
862
+ mirror_scan_recalibration = (
863
+ self.parameters.mirror_scan_recalibration_constant
864
+ ) # recalibration to the scale of the field steering mirror scanning.
865
+ # Instrument controller applies a 0.5 arcsec step, but in reality, it is 0.466 on sky
866
+
867
+ crpix3_new = (
868
+ (-(header["CNM1POS"] - header["CNM1OFF"]))
869
+ * mirror_scan_recalibration
870
+ / cdelt_updated.value[1]
871
+ )
872
+ crpix_updated = np.array([header["NAXIS2"] // 2, crpix3_new]) # y x order
873
+ pix_off_updated = pix - crpix_updated[:, None]
874
+ #################################################################################
875
+
876
+ # update WCI information
877
+ #################################################################################
878
+ slit_center_x = (
879
+ -(header["CNM1POS"] - header["CNM1OFF"])
880
+ ) * mirror_scan_recalibration # SCALE CORRECTION
881
+ slit_center_y = (
882
+ (header["CNM1BPOS"] - header["CNM1BOFF"])
883
+ ) * mirror_scan_recalibration # SCALE CORRECTION.
884
+
885
+ # angular values of observed pixels in frame of instrument
886
+ observed_pix_y = (
887
+ np.linspace(-header["NAXIS2"] / 2, header["NAXIS2"] / 2, header["NAXIS2"])
888
+ * spatial_scale_along_slit.value
889
+ + slit_center_y
890
+ )
891
+ observed_pix_x = np.zeros(header["NAXIS2"]) + slit_center_x
892
+
893
+ # angular values of observed pixels rotated into the solar frame by accounting for the telescope geometry
894
+ theta = np.deg2rad(slit_orientation_angle)
895
+ observed_pix_x_rotated = np.cos(theta) * observed_pix_x - np.sin(theta) * observed_pix_y
896
+ observed_pix_y_rotated = np.sin(theta) * observed_pix_x + np.cos(theta) * observed_pix_y
897
+ observed_pix_x_rotated += header["CRVAL3"]
898
+ observed_pix_y_rotated += header["CRVAL2"]
899
+ wci_updated = np.stack((observed_pix_y_rotated, observed_pix_x_rotated))
900
+ ##################################################################################
901
+
902
+ # correct pcij values
903
+ ##################################################################################
904
+ # CRVAL remains fixed
905
+ crval = np.array([header["CRVAL2"], header["CRVAL3"]])
906
+ pcij_updated = (
907
+ (wci_updated - crval[:, None])
908
+ @ np.linalg.pinv(pix_off_updated[:, :])
909
+ / cdelt_updated.value[:, None]
910
+ )
911
+ ##################################################################################
912
+
913
+ return (
914
+ observed_pix_x_rotated,
915
+ observed_pix_y_rotated,
916
+ cdelt_updated,
917
+ pix_off_updated,
918
+ crpix_updated,
919
+ pcij_updated,
920
+ )
921
+
922
+ def update_helioprojective_headers(
923
+ self,
924
+ header: fits.Header,
925
+ pcij_updated: np.ndarray,
926
+ crpix_updated: np.ndarray,
927
+ cdelt_updated: np.ndarray,
928
+ ) -> None:
929
+ """Update the helioprojective headers with corrected helioprojective coordinates for the SP arm."""
930
+ # UPDATE VALUES FOR HPLT-TAN / HPLN-TAN axes
931
+ header["PC1_1"] = 1.0
932
+ header["PC1_2"] = 0.0
933
+ header["PC1_3"] = 0.0
934
+ header["PC3_1"] = 0.0
935
+ header["PC2_1"] = 0.0
936
+ header["PC2_2"] = pcij_updated[0, 0]
937
+ header["PC2_3"] = pcij_updated[0, 1]
938
+ header["PC3_2"] = pcij_updated[1, 0]
939
+ header["PC3_3"] = pcij_updated[1, 1]
940
+ header["CRPIX3"] = crpix_updated[1]
941
+ header["CRPIX2"] = crpix_updated[0]
942
+ header["CDELT3"] = cdelt_updated[1]
943
+ header["CDELT2"] = cdelt_updated[0]
944
+
945
+ return
946
+
947
+ def get_arm_specific_coords(
948
+ self,
949
+ header: fits.Header,
950
+ t0: Time,
951
+ observed_pix_x_rotated: np.ndarray,
952
+ observed_pix_y_rotated: np.ndarray,
953
+ ) -> tuple[float, float, np.ndarray, np.ndarray]:
954
+ """Get SP specific coordinates to use in equatorial coordinate correction."""
955
+ x, y = header["CRVAL3"], header["CRVAL2"]
956
+
957
+ sky_coord = SkyCoord(
958
+ observed_pix_x_rotated * u.arcsec,
959
+ observed_pix_y_rotated * u.arcsec,
960
+ obstime=t0,
961
+ observer=self.location_of_dkist.get_itrs(t0),
962
+ frame="helioprojective",
963
+ )
964
+ with Helioprojective.assume_spherical_screen(sky_coord.observer):
965
+ sky_coord_earth_equatorial = sky_coord.transform_to(GeocentricEarthEquatorial)
966
+ wci_updated_eq = np.stack(
967
+ (sky_coord_earth_equatorial.lat.value, sky_coord_earth_equatorial.lon.value)
968
+ )
969
+
970
+ sky_coord = SkyCoord(
971
+ x * u.arcsec,
972
+ y * u.arcsec,
973
+ obstime=t0,
974
+ observer=self.location_of_dkist.get_itrs(t0),
975
+ frame="helioprojective",
976
+ )
977
+ with Helioprojective.assume_spherical_screen(sky_coord.observer):
978
+ sky_coord_earth_equatorial = sky_coord.transform_to(GeocentricEarthEquatorial)
979
+ crval_eq = np.stack(
980
+ (sky_coord_earth_equatorial.lat.value, sky_coord_earth_equatorial.lon.value)
981
+ )
982
+
983
+ return x, y, wci_updated_eq, crval_eq
984
+
985
+ def update_equatorial_headers(
986
+ self,
987
+ header: fits.Header,
988
+ pcij_updated_eq: np.ndarray,
989
+ crpix_updated: np.ndarray,
990
+ cdelt_eq: np.ndarray,
991
+ slit_orientation_angle: float,
992
+ ) -> None:
993
+ """Update the equatorial headers with corrected equatorial coordinates for SP arm."""
994
+ header["PC1_1A"] = 1.0
995
+ header["PC1_2A"] = 0.0
996
+ header["PC1_3A"] = 0.0
997
+ header["PC3_1A"] = 0.0
998
+ header["PC2_1A"] = 0.0
999
+ header["PC3_3A"] = pcij_updated_eq[1, 1]
1000
+ header["PC2_2A"] = pcij_updated_eq[0, 0]
1001
+ header["PC2_3A"] = pcij_updated_eq[0, 1]
1002
+ header["PC3_2A"] = pcij_updated_eq[1, 0]
1003
+ header["CRPIX3A"] = crpix_updated[1]
1004
+ header["CRPIX2A"] = crpix_updated[0]
1005
+ header["CDELT3A"] = cdelt_eq[1]
1006
+ header["CDELT2A"] = cdelt_eq[0]
1007
+ header["SLITORI"] = slit_orientation_angle
1008
+
1009
+ return
1010
+
1011
+ @cached_property
1012
+ def spectral_fit_results(self) -> dict:
1013
+ """Get the spectral fit results."""
1014
+ fit_dict = next(
1015
+ self.read(
1016
+ tags=[CryonirspTag.task_spectral_fit(), CryonirspTag.intermediate()],
1017
+ decoder=asdf_decoder,
1018
+ )
1019
+ )
1020
+ del fit_dict["asdf_library"]
1021
+ del fit_dict["history"]
1022
+ return fit_dict
1023
+
1024
+ def update_spectral_headers(self, header: fits.Header):
1025
+ """Update spectral headers after spectral correction."""
1026
+ for key, value in self.spectral_fit_results.items():
1027
+ # update the headers
1028
+ header[key] = value
1029
+
1030
+ header["CTYPE1"] = "AWAV-GRA"
1031
+ header["CUNIT1"] = "nm"
1032
+ header["CTYPE1A"] = "AWAV-GRA"
1033
+ header["CUNIT1A"] = "nm"