dkist-processing-visp 5.1.2rc1__py3-none-any.whl → 5.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. dkist_processing_visp/models/parameters.py +43 -1
  2. dkist_processing_visp/models/tags.py +11 -0
  3. dkist_processing_visp/models/task_name.py +2 -0
  4. dkist_processing_visp/tasks/geometric.py +1 -1
  5. dkist_processing_visp/tasks/science.py +88 -11
  6. dkist_processing_visp/tasks/solar.py +12 -205
  7. dkist_processing_visp/tasks/wavelength_calibration.py +430 -0
  8. dkist_processing_visp/tasks/write_l1.py +2 -0
  9. dkist_processing_visp/tests/conftest.py +11 -0
  10. dkist_processing_visp/tests/header_models.py +22 -6
  11. dkist_processing_visp/tests/local_trial_workflows/l0_cals_only.py +21 -0
  12. dkist_processing_visp/tests/local_trial_workflows/l0_polcals_as_science.py +21 -0
  13. dkist_processing_visp/tests/local_trial_workflows/l0_solar_gain_as_science.py +20 -0
  14. dkist_processing_visp/tests/local_trial_workflows/l0_to_l1.py +21 -0
  15. dkist_processing_visp/tests/local_trial_workflows/local_trial_helpers.py +27 -0
  16. dkist_processing_visp/tests/test_parameters.py +11 -5
  17. dkist_processing_visp/tests/test_science.py +60 -5
  18. dkist_processing_visp/tests/test_solar.py +0 -1
  19. dkist_processing_visp/tests/test_wavelength_calibration.py +297 -0
  20. dkist_processing_visp/tests/test_write_l1.py +0 -2
  21. dkist_processing_visp/workflows/l0_processing.py +4 -1
  22. dkist_processing_visp/workflows/trial_workflows.py +7 -2
  23. {dkist_processing_visp-5.1.2rc1.dist-info → dkist_processing_visp-5.2.0.dist-info}/METADATA +37 -37
  24. {dkist_processing_visp-5.1.2rc1.dist-info → dkist_processing_visp-5.2.0.dist-info}/RECORD +29 -27
  25. docs/gain_correction.rst +3 -0
  26. docs/index.rst +1 -0
  27. docs/wavelength_calibration.rst +64 -0
  28. changelog/251.feature.rst +0 -1
  29. {dkist_processing_visp-5.1.2rc1.dist-info → dkist_processing_visp-5.2.0.dist-info}/WHEEL +0 -0
  30. {dkist_processing_visp-5.1.2rc1.dist-info → dkist_processing_visp-5.2.0.dist-info}/top_level.txt +0 -0
@@ -5,6 +5,7 @@ from random import randint
5
5
  from typing import Any
6
6
 
7
7
  import astropy.units as u
8
+ from astropy.units import Quantity
8
9
  from dkist_processing_common.models.parameters import ParameterArmIdMixin
9
10
  from dkist_processing_common.models.parameters import ParameterBase
10
11
  from dkist_processing_common.models.parameters import ParameterWavelengthMixin
@@ -200,7 +201,7 @@ class VispParameters(ParameterBase, ParameterWavelengthMixin, ParameterArmIdMixi
200
201
  @property
201
202
  def solar_vignette_dispersion_bounds_fraction(self) -> float:
202
203
  """
203
- Define the ± fraction away from the initial value for bounds on dispersion when fitting the initial vignette signal.
204
+ Define the ± fraction from the initial value for bounds on dispersion when fitting the initial vignette signal.
204
205
 
205
206
  This value should be between 0 and 1. For example, the minimum bound is `init_value * (1 - solar_vignette_dispersion_bounds_fraction)`.
206
207
  """
@@ -230,6 +231,33 @@ class VispParameters(ParameterBase, ParameterWavelengthMixin, ParameterArmIdMixi
230
231
  """Return fractional number of samples required for the RANSAC regressor used to fit the 2D vignette signal."""
231
232
  return self._find_most_recent_past_value("visp_solar_vignette_min_samples")
232
233
 
234
+ @property
235
+ def wavecal_crval_bounds_px(self) -> Quantity:
236
+ """
237
+ Define the bounds (in *pix*) on crval when performing wavecal fitting.
238
+
239
+ The actual bounds on the value of crval are equal to ± the initial dispersion times this number. Note that the
240
+ total range searched by the fitting algorithm will be twice this number (in pixels).
241
+ """
242
+ return self._find_most_recent_past_value("visp_wavecal_crval_bounds_px") * u.pix
243
+
244
+ @property
245
+ def wavecal_dispersion_bounds_fraction(self) -> Quantity:
246
+ """
247
+ Define the ± fraction from the initial value for bounds on dispersion when performing wavecal fitting.
248
+
249
+ This value should be between 0 and 1. For example, the minimum bound is `init_value * (1 - wavecal_dispersion_bounds_fraction)`.
250
+ """
251
+ return self._find_most_recent_past_value("visp_wavecal_dispersion_bounds_fraction")
252
+
253
+ @property
254
+ def wavecal_incident_light_angle_bounds_deg(self) -> Quantity:
255
+ """Define the bounds (in *deg*) on incident_light_angle when performing wavecal fitting."""
256
+ return (
257
+ self._find_most_recent_past_value("visp_wavecal_incident_light_angle_bounds_deg")
258
+ * u.deg
259
+ )
260
+
233
261
  @property
234
262
  def wavecal_camera_lens_parameters(self) -> list[u.Quantity]:
235
263
  r"""
@@ -279,6 +307,20 @@ class VispParameters(ParameterBase, ParameterWavelengthMixin, ParameterArmIdMixi
279
307
  """Define the initial guess for opacity factor in wavecal fits."""
280
308
  return self._find_most_recent_past_value("visp_wavecal_init_opacity_factor")
281
309
 
310
+ @property
311
+ def wavecal_fit_kwargs(self) -> dict[str, Any]:
312
+ """Define extra keyword arguments to pass to the wavelength calibration fitter."""
313
+ doc_dict = self._find_most_recent_past_value("visp_wavecal_fit_kwargs")
314
+ rng_kwarg = dict()
315
+ fitting_method = doc_dict.get("method", False)
316
+ if fitting_method in ["basinhopping", "differential_evolution", "dual_annealing"]:
317
+ rng = randint(1, 1_000_000)
318
+ rng_kwarg["rng"] = rng
319
+
320
+ # The order here allows us to override `rng` in a parameter value
321
+ fit_kwargs = rng_kwarg | doc_dict
322
+ return fit_kwargs
323
+
282
324
  @property
283
325
  def polcal_spatial_median_filter_width_px(self) -> int:
284
326
  """Return the size of the median filter to apply in the spatial dimension to polcal data."""
@@ -2,6 +2,7 @@
2
2
 
3
3
  from enum import Enum
4
4
 
5
+ from dkist_processing_common.models.tags import StemName
5
6
  from dkist_processing_common.models.tags import Tag
6
7
 
7
8
  from dkist_processing_visp.models.task_name import VispTaskName
@@ -63,6 +64,16 @@ class VispTag(Tag):
63
64
  """
64
65
  return cls.format_tag(VispStemName.map_scan, map_scan_num)
65
66
 
67
+ @classmethod
68
+ def task_characteristic_spectra(cls) -> str:
69
+ """Tags intermediate characteristic spectra."""
70
+ return cls.format_tag(StemName.task, VispTaskName.solar_char_spec.value)
71
+
72
+ @classmethod
73
+ def task_wavelength_calibration(cls) -> str:
74
+ """Tags wavelength calibration."""
75
+ return cls.format_tag(StemName.task, VispTaskName.wavelength_calibration.value)
76
+
66
77
  ##################
67
78
  # Composite tags #
68
79
  ##################
@@ -7,3 +7,5 @@ class VispTaskName(str, Enum):
7
7
  """Controlled list of task tag names."""
8
8
 
9
9
  background = "BACKGROUND"
10
+ solar_char_spec = "SOLAR_CHAR_SPEC"
11
+ wavelength_calibration = "WAVELENGTH_CALIBRATION"
@@ -1086,7 +1086,7 @@ class GeometricCalibration(
1086
1086
  "rel_height": self.parameters.geo_zone_rel_height,
1087
1087
  }
1088
1088
  zones = self.compute_line_zones(array, **zone_kwargs)
1089
- logger.info(f"Found {zones = }")
1089
+ logger.info(f"Found zones = {[tuple(int(i) for i in z) for z in zones]}")
1090
1090
  mask = np.zeros(array.shape).astype(bool)
1091
1091
  for z in zones:
1092
1092
  mask[z[0] : z[1], :] = True
@@ -12,6 +12,7 @@ from astropy.time import TimeDelta
12
12
  from dkist_processing_common.codecs.fits import fits_access_decoder
13
13
  from dkist_processing_common.codecs.fits import fits_array_decoder
14
14
  from dkist_processing_common.codecs.fits import fits_hdulist_encoder
15
+ from dkist_processing_common.codecs.json import json_decoder
15
16
  from dkist_processing_common.models.fits_access import MetadataKey
16
17
  from dkist_processing_common.models.task_name import TaskName
17
18
  from dkist_processing_common.tasks.mixin.quality import QualityMixin
@@ -42,6 +43,7 @@ class CalibrationCollection:
42
43
  angle: dict
43
44
  state_offset: dict
44
45
  spec_shift: dict
46
+ wavelength_calibration_header: dict
45
47
  demod_matrices: dict | None
46
48
 
47
49
  @cached_property
@@ -176,6 +178,15 @@ class ScienceCalibration(
176
178
  spec_shift_dict = dict()
177
179
  demod_dict = dict() if self.constants.correct_for_polarization else None
178
180
 
181
+ # WaveCal
182
+ #########
183
+ wavecal_header = next(
184
+ self.read(
185
+ tags=[VispTag.intermediate(), VispTag.task_wavelength_calibration()],
186
+ decoder=json_decoder,
187
+ )
188
+ )
189
+
179
190
  for beam in range(1, self.constants.num_beams + 1):
180
191
  for readout_exp_time in self.constants.observe_readout_exp_times:
181
192
  # Dark
@@ -270,6 +281,7 @@ class ScienceCalibration(
270
281
  angle=angle_dict,
271
282
  state_offset=state_offset_dict,
272
283
  spec_shift=spec_shift_dict,
284
+ wavelength_calibration_header=wavecal_header,
273
285
  demod_matrices=demod_dict,
274
286
  )
275
287
 
@@ -761,22 +773,31 @@ class ScienceCalibration(
761
773
  """
762
774
  Update calibrated headers with any information gleaned during science calibration.
763
775
 
764
- Right now all this does is put map scan values in the header.
776
+ #. Apply the wavelength calibration header values
777
+ #. Add map scan keywords
778
+ #. Adjust CRPIX values based on any chopping needed to return only regions where the beams overlap
765
779
 
766
780
  Parameters
767
781
  ----------
768
- header : fits.Header
782
+ header
769
783
  The header to update
770
784
 
771
- map_scan : int
785
+ map_scan
772
786
  Current map scan
773
787
 
788
+ calibrations
789
+ Container of intermediate calibration objects. Used to figure out how much to adjust CRPIX values.
790
+
774
791
  Returns
775
792
  -------
776
793
  fits.Header
777
794
  Updated header
778
795
 
779
796
  """
797
+ # Apply the wavelength calibration
798
+ # This needs to be done prior to adjusting CRPIX values below
799
+ header.update(calibrations.wavelength_calibration_header)
800
+
780
801
  # Update the map scan number
781
802
  header["VSPNMAPS"] = self.constants.num_map_scans
782
803
  header["VSPMAP"] = map_scan
@@ -784,27 +805,51 @@ class ScienceCalibration(
784
805
  # Adjust the CRPIX values if the beam overlap slicing chopped from the start of the array
785
806
  x_slice, y_slice = calibrations.beams_overlap_slice
786
807
 
808
+ # Note: We KNOW that `x_slice` and `y_slice` correspond to the *array* dimensions corresponding to spectral and
809
+ # spatial directions, respectively. Some early ViSP data swap the *WCS* dimensions so that CRPIX1 contains
810
+ # information about the spectral axis, even though the spectral axis is always in the 0th array axis (and thus
811
+ # the second WCS axis because FITS and numpy are backwards; are we confused yet?).
812
+ #
813
+ # We want the adjustment of the CRPIX values to produce accurate WCS information, even if they're associated
814
+ # with the wrong array axes. For this reason we dynamically associate the WCS axis number with `x_slice` and
815
+ # `y_slice` via the `*_wcs_axis_num` properties.
816
+ #
817
+ # To say it differently, we KNOW that `x_slice` always refers to chopping in the spectral dimension, so we need
818
+ # to update the CRPIX associate with the spectral WCS, no matter which WCS axis that is.
819
+
787
820
  # This if catches 0's and Nones
788
821
  if x_slice.start:
789
822
  # .start will only be non-None or 0 if the slice is from the start. In this case we need to update the WCS
790
823
  logger.info(
791
- f"Adjusting CRPIX2 from {header['CRPIX2']} to {header['CRPIX2'] - x_slice.start}"
824
+ f"Adjusting spectral CRPIX{self.spectral_wcs_axis_num} from "
825
+ f"{header[f'CRPIX{self.spectral_wcs_axis_num}']} to {header[f'CRPIX{self.spectral_wcs_axis_num}'] - x_slice.start}"
826
+ )
827
+ header[f"CRPIX{self.spectral_wcs_axis_num}"] = (
828
+ header[f"CRPIX{self.spectral_wcs_axis_num}"] - x_slice.start
792
829
  )
793
- header["CRPIX2"] = header["CRPIX2"] - x_slice.start
794
830
  logger.info(
795
- f"Adjusting CRPIX2A from {header['CRPIX2A']} to {header['CRPIX2A'] - x_slice.start}"
831
+ f"Adjusting spectral CRPIX{self.spectral_wcs_axis_num}A from "
832
+ f"{header[f'CRPIX{self.spectral_wcs_axis_num}A']} to {header[f'CRPIX{self.spectral_wcs_axis_num}A'] - x_slice.start}"
833
+ )
834
+ header[f"CRPIX{self.spectral_wcs_axis_num}A"] = (
835
+ header[f"CRPIX{self.spectral_wcs_axis_num}A"] - x_slice.start
796
836
  )
797
- header["CRPIX2A"] = header["CRPIX2A"] - x_slice.start
798
837
 
799
838
  if y_slice.start:
800
839
  logger.info(
801
- f"Adjusting CRPIX1 from {header['CRPIX1']} to {header['CRPIX1'] - y_slice.start}"
840
+ f"Adjusting spatial CRPIX{self.spatial_wcs_axis_num} from "
841
+ f"{header[f'CRPIX{self.spatial_wcs_axis_num}']} to {header[f'CRPIX{self.spatial_wcs_axis_num}'] - y_slice.start}"
842
+ )
843
+ header[f"CRPIX{self.spatial_wcs_axis_num}"] = (
844
+ header[f"CRPIX{self.spatial_wcs_axis_num}"] - y_slice.start
802
845
  )
803
- header["CRPIX1"] = header["CRPIX1"] - y_slice.start
804
846
  logger.info(
805
- f"Adjusting CRPIX1A from {header['CRPIX1A']} to {header['CRPIX1A'] - y_slice.start}"
847
+ f"Adjusting spatial CRPIX{self.spatial_wcs_axis_num}A from "
848
+ f"{header[f'CRPIX{self.spatial_wcs_axis_num}A']} to {header[f'CRPIX{self.spatial_wcs_axis_num}A'] - y_slice.start}"
849
+ )
850
+ header[f"CRPIX{self.spatial_wcs_axis_num}A"] = (
851
+ header[f"CRPIX{self.spatial_wcs_axis_num}A"] - y_slice.start
806
852
  )
807
- header["CRPIX1A"] = header["CRPIX1A"] - y_slice.start
808
853
 
809
854
  return header
810
855
 
@@ -900,3 +945,35 @@ class ScienceCalibration(
900
945
 
901
946
  filename = next(self.read(tags=tags))
902
947
  logger.info(f"Wrote intermediate file for {tags = } to {filename}")
948
+
949
+ @cached_property
950
+ def spectral_wcs_axis_num(self) -> int:
951
+ """
952
+ Return the WCS axis number corresponding to wavelength.
953
+
954
+ We need to check this dynamically because some early ViSP data got the WCS backward w.r.t. the array dimensions.
955
+ """
956
+ try:
957
+ spectral_axis_num = next(
958
+ (i for i in range(1, 4) if getattr(self.constants, f"axis_{i}_type") == "AWAV")
959
+ )
960
+ except StopIteration as e:
961
+ raise ValueError("Could not find WCS axis with type AWAV") from e
962
+
963
+ return spectral_axis_num
964
+
965
+ @cached_property
966
+ def spatial_wcs_axis_num(self) -> int:
967
+ """
968
+ Return the WCS axis number corresponding to Helioprojective latitude.
969
+
970
+ We need to check this dynamically because some early ViSP data got the WCS backward w.r.t. the array dimensions.
971
+ """
972
+ try:
973
+ spatial_axis_num = next(
974
+ (i for i in range(1, 4) if getattr(self.constants, f"axis_{i}_type") == "HPLT-TAN")
975
+ )
976
+ except StopIteration as e:
977
+ raise ValueError("Cound not find WCS axis with type HPLT-TAN") from e
978
+
979
+ return spatial_axis_num
@@ -8,14 +8,11 @@ from typing import Callable
8
8
  import astropy.units as u
9
9
  import numpy as np
10
10
  import scipy.ndimage as spnd
11
- from astropy.time import Time
12
11
  from astropy.units import Quantity
13
- from astropy.wcs import WCS
14
12
  from dkist_processing_common.codecs.asdf import asdf_encoder
15
13
  from dkist_processing_common.codecs.fits import fits_access_decoder
16
14
  from dkist_processing_common.codecs.fits import fits_array_decoder
17
15
  from dkist_processing_common.codecs.fits import fits_array_encoder
18
- from dkist_processing_common.models.dkist_location import location_of_dkist
19
16
  from dkist_processing_common.models.task_name import TaskName
20
17
  from dkist_processing_common.tasks.mixin.quality import QualityMixin
21
18
  from dkist_processing_math.arithmetic import divide_arrays_by_array
@@ -38,9 +35,7 @@ from solar_wavelength_calibration import UnitlessBoundRange
38
35
  from solar_wavelength_calibration import WavelengthCalibrationFitter
39
36
  from solar_wavelength_calibration import WavelengthCalibrationParameters
40
37
  from solar_wavelength_calibration.fitter.wavelength_fitter import FitResult
41
- from solar_wavelength_calibration.fitter.wavelength_fitter import WavelengthParameters
42
38
  from solar_wavelength_calibration.fitter.wavelength_fitter import calculate_initial_crval_guess
43
- from sunpy.coordinates import HeliocentricInertial
44
39
 
45
40
  from dkist_processing_visp.models.metric_code import VispMetricCode
46
41
  from dkist_processing_visp.models.tags import VispTag
@@ -48,15 +43,15 @@ from dkist_processing_visp.parsers.visp_l0_fits_access import VispL0FitsAccess
48
43
  from dkist_processing_visp.tasks.mixin.beam_access import BeamAccessMixin
49
44
  from dkist_processing_visp.tasks.mixin.corrections import CorrectionsMixin
50
45
  from dkist_processing_visp.tasks.visp_base import VispTaskBase
46
+ from dkist_processing_visp.tasks.wavelength_calibration import compute_initial_dispersion
47
+ from dkist_processing_visp.tasks.wavelength_calibration import compute_input_wavelength_vector
48
+ from dkist_processing_visp.tasks.wavelength_calibration import compute_order
49
+ from dkist_processing_visp.tasks.wavelength_calibration import get_doppler_velocity
51
50
 
52
51
  __all__ = [
53
52
  "SolarCalibration",
54
53
  "WavelengthCalibrationParametersWithContinuum",
55
54
  "polynomial_continuum_model",
56
- "compute_order",
57
- "compute_initial_dispersion",
58
- "compute_doppler_velocity",
59
- "compute_input_wavelength_vector",
60
55
  ]
61
56
 
62
57
 
@@ -128,7 +123,7 @@ class SolarCalibration(
128
123
  QualityMixin,
129
124
  ):
130
125
  """
131
- Task class for generating Solar Gain images for each beam/modstate.
126
+ Task class for generating Solar Gain images for each beam.
132
127
 
133
128
  Parameters
134
129
  ----------
@@ -190,8 +185,8 @@ class SolarCalibration(
190
185
  pixel_pitch=self.parameters.wavecal_pixel_pitch_micron_per_pix,
191
186
  )
192
187
 
193
- doppler_velocity = compute_doppler_velocity(
194
- time_of_observation=self.constants.solar_gain_ip_start_time
188
+ doppler_velocity = get_doppler_velocity(
189
+ solar_gain_ip_start_time=self.constants.solar_gain_ip_start_time
195
190
  )
196
191
 
197
192
  self._log_wavecal_parameters(
@@ -247,8 +242,10 @@ class SolarCalibration(
247
242
  self.write(
248
243
  data=char_spec,
249
244
  encoder=fits_array_encoder,
250
- tags=[VispTag.debug(), VispTag.frame()],
251
- relative_path=f"DEBUG_SC_CHAR_SPEC_BEAM_{beam}.dat",
245
+ tags=[
246
+ VispTag.intermediate_frame(beam=beam),
247
+ VispTag.task_characteristic_spectra(),
248
+ ],
252
249
  overwrite=True,
253
250
  )
254
251
 
@@ -830,9 +827,6 @@ class SolarCalibration(
830
827
  beam
831
828
  The beam number for this array
832
829
 
833
- modstate
834
- The modulator state for this array
835
-
836
830
 
837
831
  Returns
838
832
  -------
@@ -958,7 +952,7 @@ class SolarCalibration(
958
952
  self, dispersion: Quantity, order: int, doppler_velocity: Quantity
959
953
  ) -> None:
960
954
  """Log initial guess and instrument-derived wavecal parameters."""
961
- logger.info(f"central wavelength = {self.constants.wavelength * u.nm !s}")
955
+ logger.info(f"central_wavelength = {self.constants.wavelength * u.nm !s}")
962
956
  logger.info(f"{dispersion = !s}")
963
957
  logger.info(f"{order = }")
964
958
  logger.info(f"grating constant = {self.constants.grating_constant_inverse_mm !s}")
@@ -996,190 +990,3 @@ def polynomial_continuum_model(
996
990
  """
997
991
  coeffs = [fit_parameters[f"poly_coeff_{i:02n}"].value for i in range(fit_order + 1)]
998
992
  return np.polyval(coeffs, abscissa)
999
-
1000
-
1001
- ##################################################################################
1002
- # TODO: The following definitions should go in the wavecal module once it exists #
1003
- ##################################################################################
1004
-
1005
-
1006
- def compute_order(
1007
- central_wavelength: Quantity,
1008
- incident_light_angle: Quantity,
1009
- reflected_light_angle: Quantity,
1010
- grating_constant: Quantity,
1011
- ) -> int:
1012
- r"""
1013
- Compute the spectral order from the spectrograph setup.
1014
-
1015
- From the grating equation, the spectral order, :math:`m`:, is
1016
-
1017
- .. math::
1018
- m = \frac{\sin\alpha + \sin\beta}{G \lambda}
1019
-
1020
- where :math:`\alpha` and :math:`\beta` are the incident and reflected light angles, respectively, :math:`G` is the
1021
- grating constant (lines per mm), and :math:`\lambda` is the central wavelength. All of these values come from the
1022
- input headers.
1023
-
1024
- Parameters
1025
- ----------
1026
- central_wavelength
1027
- Wavelength of the center of the spectral window.
1028
-
1029
- incident_light_angle
1030
- Angle of light incident to the spectrograph grating. Often called :math:`\alpha`.
1031
-
1032
- reflected_light_angle
1033
- Angle of light reflected from spectrograph grating. Often called :math:`\beta`.
1034
-
1035
- grating_constant
1036
- Grating constant of the spectrograph grating [lines per mm]
1037
-
1038
- Returns
1039
- -------
1040
- spectral_order
1041
- The order of the given spectrograph configuration
1042
- """
1043
- return int(
1044
- (np.sin(incident_light_angle) + np.sin(reflected_light_angle))
1045
- / (grating_constant * central_wavelength)
1046
- )
1047
-
1048
-
1049
- def compute_initial_dispersion(
1050
- central_wavelength: Quantity,
1051
- incident_light_angle: Quantity,
1052
- reflected_light_angle: Quantity,
1053
- lens_parameters: list[Quantity],
1054
- pixel_pitch: Quantity,
1055
- ) -> Quantity:
1056
- r"""
1057
- Compute the dispersion (:math:`d\,\lambda/d\, px`) given the spectrograph setup.
1058
-
1059
- The dispersion is given via
1060
-
1061
- .. math::
1062
- d\,\lambda / d\, px = \frac{p \lambda_0 \cos\beta}{f (\sin\alpha + \sin\beta)}
1063
-
1064
- where :math:`p` is the pixel pitch (microns per pix), :math:`\lambda_0` is the central wavelength, :math:`f` is the
1065
- camera focal length, and :math:`\alpha` and :math:`\beta` are the incident and reflected light angles, respectively.
1066
- :math:`\lambda_0`, :math:`\alpha`, and :math:`\beta` are taken from input headers, while :math:`f` and :math:`p` are
1067
- pipeline parameters.
1068
-
1069
- Parameters
1070
- ----------
1071
- central_wavelength
1072
- Wavelength of the center of the spectral window.
1073
-
1074
- incident_light_angle
1075
- Angle of light incident to the spectrograph grating. Often called :math:`\alpha`.
1076
-
1077
- reflected_light_angle
1078
- Angle of light reflected from spectrograph grating. Often called :math:`\beta`.
1079
-
1080
- lens_parameters
1081
- Parameterization of lense focal length as zero, first, and second orders of wavelength. If the total focal
1082
- length of the lens is :math:`f = a_0 + a_1\lambda + a_2\lambda^2` then this list is :math:`[a_0, a_1, a_2]`.
1083
-
1084
- pixel_pitch
1085
- The physical size of a single pixel
1086
-
1087
- Returns
1088
- -------
1089
- dispersion
1090
- The computed dispersion in units of nm / px
1091
- """
1092
- camera_focal_length = lens_parameters[0] + central_wavelength * (
1093
- lens_parameters[1] + central_wavelength * lens_parameters[2]
1094
- )
1095
- logger.info(f"{camera_focal_length = !s}")
1096
-
1097
- linear_dispersion = (
1098
- camera_focal_length
1099
- * (np.sin(incident_light_angle) + np.sin(reflected_light_angle))
1100
- / (np.cos(reflected_light_angle) * central_wavelength)
1101
- )
1102
-
1103
- dispersion = pixel_pitch / linear_dispersion
1104
-
1105
- return dispersion.to(u.nm / u.pix)
1106
-
1107
-
1108
- def compute_doppler_velocity(time_of_observation: str) -> Quantity:
1109
- """Find the speed at which DKIST is moving relative to the Sun's center.
1110
-
1111
- Positive values refer to when DKIST is moving away from the sun.
1112
-
1113
- Parameters
1114
- ----------
1115
- time_of_observation
1116
- Time at which to compute the relative Dopper velocity. Any string that can be parsed by `astropy.time.Time` is
1117
- acceptable.
1118
-
1119
- Returns
1120
- -------
1121
- doppler_velocity
1122
- The relative velocity between observer and the Sun with units of km / s
1123
- """
1124
- coord = location_of_dkist.get_gcrs(obstime=Time(time_of_observation))
1125
- heliocentric_coord = coord.transform_to(HeliocentricInertial(obstime=Time(time_of_observation)))
1126
- obs_vr_kms = heliocentric_coord.d_distance
1127
- return obs_vr_kms
1128
-
1129
-
1130
- def compute_input_wavelength_vector(
1131
- *,
1132
- central_wavelength: Quantity,
1133
- dispersion: Quantity,
1134
- grating_constant: Quantity,
1135
- order: int,
1136
- incident_light_angle: Quantity,
1137
- num_spec_px: int,
1138
- ) -> u.Quantity:
1139
- r"""
1140
- Compute a wavelength vector based on information about the spectrograph setup.
1141
-
1142
- The parameterization of the grating equation is via `astropy.wcs.WCS`, which follows section 5 of
1143
- `Greisen et al (2006) <https://ui.adsabs.harvard.edu/abs/2006A%26A...446..747G/abstract>`_.
1144
-
1145
- Parameters
1146
- ----------
1147
- central_wavelength
1148
- Wavelength at the center of the spectral window. This function forces the value of the output vector to be
1149
- ``central_wavelength`` at index ``num_spec // 2 + 1``.
1150
-
1151
- dispersion
1152
- Spectrograph dispersion [nm / px]
1153
-
1154
- grating_constant
1155
- Grating constant of the spectrograph grating [lines per mm]
1156
-
1157
- order
1158
- Spectrograph order
1159
-
1160
- incident_light_angle
1161
- Angle of light incident to the spectrograph grating. Often called :math:`\alpha`.
1162
-
1163
- num_spec_px
1164
- The length of the output wavelength vector. Defines size and physical limits of the output.
1165
-
1166
- Returns
1167
- -------
1168
- wave_vec
1169
- 1D array of length ``num_spec`` containing the wavelength values described by the input WCS parameterization.
1170
- The units of this array will be nanometers.
1171
- """
1172
- wavelength_parameters = WavelengthParameters(
1173
- crpix=num_spec_px // 2 + 1,
1174
- crval=central_wavelength.to_value(u.nm),
1175
- dispersion=dispersion.to_value(u.nm / u.pix),
1176
- grating_constant=grating_constant.to_value(1 / u.mm),
1177
- order=order,
1178
- incident_light_angle=incident_light_angle.to_value(u.deg),
1179
- cunit="nm",
1180
- )
1181
- header = wavelength_parameters.to_header(axis_num=1)
1182
- wcs = WCS(header)
1183
- input_wavelength_vector = wcs.spectral.pixel_to_world(np.arange(num_spec_px)).to(u.nm)
1184
-
1185
- return input_wavelength_vector