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.
- changelog/.gitempty +0 -0
- dkist_processing_cryonirsp/__init__.py +11 -0
- dkist_processing_cryonirsp/config.py +12 -0
- dkist_processing_cryonirsp/models/__init__.py +1 -0
- dkist_processing_cryonirsp/models/constants.py +248 -0
- dkist_processing_cryonirsp/models/exposure_conditions.py +26 -0
- dkist_processing_cryonirsp/models/parameters.py +296 -0
- dkist_processing_cryonirsp/models/tags.py +168 -0
- dkist_processing_cryonirsp/models/task_name.py +14 -0
- dkist_processing_cryonirsp/parsers/__init__.py +1 -0
- dkist_processing_cryonirsp/parsers/cryonirsp_l0_fits_access.py +111 -0
- dkist_processing_cryonirsp/parsers/cryonirsp_l1_fits_access.py +30 -0
- dkist_processing_cryonirsp/parsers/exposure_conditions.py +163 -0
- dkist_processing_cryonirsp/parsers/map_repeats.py +40 -0
- dkist_processing_cryonirsp/parsers/measurements.py +55 -0
- dkist_processing_cryonirsp/parsers/modstates.py +31 -0
- dkist_processing_cryonirsp/parsers/optical_density_filters.py +40 -0
- dkist_processing_cryonirsp/parsers/polarimetric_check.py +120 -0
- dkist_processing_cryonirsp/parsers/scan_step.py +412 -0
- dkist_processing_cryonirsp/parsers/time.py +80 -0
- dkist_processing_cryonirsp/parsers/wavelength.py +26 -0
- dkist_processing_cryonirsp/tasks/__init__.py +19 -0
- dkist_processing_cryonirsp/tasks/assemble_movie.py +202 -0
- dkist_processing_cryonirsp/tasks/bad_pixel_map.py +96 -0
- dkist_processing_cryonirsp/tasks/beam_boundaries_base.py +279 -0
- dkist_processing_cryonirsp/tasks/ci_beam_boundaries.py +55 -0
- dkist_processing_cryonirsp/tasks/ci_science.py +169 -0
- dkist_processing_cryonirsp/tasks/cryonirsp_base.py +67 -0
- dkist_processing_cryonirsp/tasks/dark.py +98 -0
- dkist_processing_cryonirsp/tasks/gain.py +251 -0
- dkist_processing_cryonirsp/tasks/instrument_polarization.py +447 -0
- dkist_processing_cryonirsp/tasks/l1_output_data.py +44 -0
- dkist_processing_cryonirsp/tasks/linearity_correction.py +582 -0
- dkist_processing_cryonirsp/tasks/make_movie_frames.py +302 -0
- dkist_processing_cryonirsp/tasks/mixin/__init__.py +1 -0
- dkist_processing_cryonirsp/tasks/mixin/beam_access.py +52 -0
- dkist_processing_cryonirsp/tasks/mixin/corrections.py +177 -0
- dkist_processing_cryonirsp/tasks/mixin/intermediate_frame.py +193 -0
- dkist_processing_cryonirsp/tasks/mixin/linearized_frame.py +309 -0
- dkist_processing_cryonirsp/tasks/mixin/shift_measurements.py +297 -0
- dkist_processing_cryonirsp/tasks/parse.py +281 -0
- dkist_processing_cryonirsp/tasks/quality_metrics.py +271 -0
- dkist_processing_cryonirsp/tasks/science_base.py +511 -0
- dkist_processing_cryonirsp/tasks/sp_beam_boundaries.py +270 -0
- dkist_processing_cryonirsp/tasks/sp_dispersion_axis_correction.py +484 -0
- dkist_processing_cryonirsp/tasks/sp_geometric.py +585 -0
- dkist_processing_cryonirsp/tasks/sp_science.py +299 -0
- dkist_processing_cryonirsp/tasks/sp_solar_gain.py +475 -0
- dkist_processing_cryonirsp/tasks/trial_output_data.py +61 -0
- dkist_processing_cryonirsp/tasks/write_l1.py +1033 -0
- dkist_processing_cryonirsp/tests/__init__.py +1 -0
- dkist_processing_cryonirsp/tests/conftest.py +456 -0
- dkist_processing_cryonirsp/tests/header_models.py +592 -0
- dkist_processing_cryonirsp/tests/local_trial_workflows/__init__.py +0 -0
- dkist_processing_cryonirsp/tests/local_trial_workflows/l0_cals_only.py +541 -0
- dkist_processing_cryonirsp/tests/local_trial_workflows/l0_to_l1.py +615 -0
- dkist_processing_cryonirsp/tests/local_trial_workflows/linearize_only.py +96 -0
- dkist_processing_cryonirsp/tests/local_trial_workflows/local_trial_helpers.py +592 -0
- dkist_processing_cryonirsp/tests/test_assemble_movie.py +144 -0
- dkist_processing_cryonirsp/tests/test_assemble_qualilty.py +517 -0
- dkist_processing_cryonirsp/tests/test_bad_pixel_maps.py +115 -0
- dkist_processing_cryonirsp/tests/test_ci_beam_boundaries.py +106 -0
- dkist_processing_cryonirsp/tests/test_ci_science.py +355 -0
- dkist_processing_cryonirsp/tests/test_corrections.py +126 -0
- dkist_processing_cryonirsp/tests/test_cryo_base.py +202 -0
- dkist_processing_cryonirsp/tests/test_cryo_constants.py +76 -0
- dkist_processing_cryonirsp/tests/test_dark.py +287 -0
- dkist_processing_cryonirsp/tests/test_gain.py +278 -0
- dkist_processing_cryonirsp/tests/test_instrument_polarization.py +531 -0
- dkist_processing_cryonirsp/tests/test_linearity_correction.py +245 -0
- dkist_processing_cryonirsp/tests/test_make_movie_frames.py +111 -0
- dkist_processing_cryonirsp/tests/test_parameters.py +266 -0
- dkist_processing_cryonirsp/tests/test_parse.py +1439 -0
- dkist_processing_cryonirsp/tests/test_quality.py +203 -0
- dkist_processing_cryonirsp/tests/test_sp_beam_boundaries.py +112 -0
- dkist_processing_cryonirsp/tests/test_sp_dispersion_axis_correction.py +155 -0
- dkist_processing_cryonirsp/tests/test_sp_geometric.py +319 -0
- dkist_processing_cryonirsp/tests/test_sp_make_movie_frames.py +121 -0
- dkist_processing_cryonirsp/tests/test_sp_science.py +483 -0
- dkist_processing_cryonirsp/tests/test_sp_solar.py +198 -0
- dkist_processing_cryonirsp/tests/test_trial_create_quality_report.py +79 -0
- dkist_processing_cryonirsp/tests/test_trial_output_data.py +251 -0
- dkist_processing_cryonirsp/tests/test_workflows.py +9 -0
- dkist_processing_cryonirsp/tests/test_write_l1.py +436 -0
- dkist_processing_cryonirsp/workflows/__init__.py +2 -0
- dkist_processing_cryonirsp/workflows/ci_l0_processing.py +77 -0
- dkist_processing_cryonirsp/workflows/sp_l0_processing.py +84 -0
- dkist_processing_cryonirsp/workflows/trial_workflows.py +190 -0
- dkist_processing_cryonirsp-1.3.4.dist-info/METADATA +194 -0
- dkist_processing_cryonirsp-1.3.4.dist-info/RECORD +111 -0
- dkist_processing_cryonirsp-1.3.4.dist-info/WHEEL +5 -0
- dkist_processing_cryonirsp-1.3.4.dist-info/top_level.txt +4 -0
- docs/Makefile +134 -0
- docs/bad_pixel_calibration.rst +47 -0
- docs/beam_angle_calculation.rst +53 -0
- docs/beam_boundary_computation.rst +88 -0
- docs/changelog.rst +7 -0
- docs/ci_science_calibration.rst +33 -0
- docs/conf.py +52 -0
- docs/index.rst +21 -0
- docs/l0_to_l1_cryonirsp_ci-full-trial.rst +10 -0
- docs/l0_to_l1_cryonirsp_ci.rst +10 -0
- docs/l0_to_l1_cryonirsp_sp-full-trial.rst +10 -0
- docs/l0_to_l1_cryonirsp_sp.rst +10 -0
- docs/linearization.rst +43 -0
- docs/make.bat +170 -0
- docs/requirements.txt +1 -0
- docs/requirements_table.rst +8 -0
- docs/scientific_changelog.rst +10 -0
- docs/sp_science_calibration.rst +59 -0
- 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"
|