dkist-processing-visp 2.20.14__py3-none-any.whl → 5.1.1__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 (73) hide show
  1. dkist_processing_visp/__init__.py +1 -0
  2. dkist_processing_visp/config.py +1 -0
  3. dkist_processing_visp/models/constants.py +61 -20
  4. dkist_processing_visp/models/fits_access.py +20 -0
  5. dkist_processing_visp/models/metric_code.py +10 -0
  6. dkist_processing_visp/models/parameters.py +129 -24
  7. dkist_processing_visp/models/tags.py +22 -1
  8. dkist_processing_visp/models/task_name.py +1 -0
  9. dkist_processing_visp/parsers/map_repeats.py +1 -0
  10. dkist_processing_visp/parsers/modulator_states.py +1 -0
  11. dkist_processing_visp/parsers/polarimeter_mode.py +4 -2
  12. dkist_processing_visp/parsers/raster_step.py +4 -1
  13. dkist_processing_visp/parsers/spectrograph_configuration.py +75 -0
  14. dkist_processing_visp/parsers/time.py +24 -14
  15. dkist_processing_visp/parsers/visp_l0_fits_access.py +19 -8
  16. dkist_processing_visp/parsers/visp_l1_fits_access.py +1 -0
  17. dkist_processing_visp/tasks/__init__.py +1 -0
  18. dkist_processing_visp/tasks/assemble_movie.py +1 -0
  19. dkist_processing_visp/tasks/background_light.py +2 -1
  20. dkist_processing_visp/tasks/dark.py +5 -4
  21. dkist_processing_visp/tasks/geometric.py +132 -20
  22. dkist_processing_visp/tasks/instrument_polarization.py +128 -18
  23. dkist_processing_visp/tasks/l1_output_data.py +203 -0
  24. dkist_processing_visp/tasks/lamp.py +53 -93
  25. dkist_processing_visp/tasks/make_movie_frames.py +8 -6
  26. dkist_processing_visp/tasks/mixin/beam_access.py +1 -0
  27. dkist_processing_visp/tasks/mixin/corrections.py +54 -4
  28. dkist_processing_visp/tasks/mixin/downsample.py +1 -0
  29. dkist_processing_visp/tasks/parse.py +50 -17
  30. dkist_processing_visp/tasks/quality_metrics.py +5 -4
  31. dkist_processing_visp/tasks/science.py +126 -46
  32. dkist_processing_visp/tasks/solar.py +896 -456
  33. dkist_processing_visp/tasks/visp_base.py +4 -3
  34. dkist_processing_visp/tasks/write_l1.py +38 -10
  35. dkist_processing_visp/tests/conftest.py +145 -47
  36. dkist_processing_visp/tests/header_models.py +157 -20
  37. dkist_processing_visp/tests/local_trial_workflows/l0_cals_only.py +21 -78
  38. dkist_processing_visp/tests/local_trial_workflows/l0_polcals_as_science.py +421 -0
  39. dkist_processing_visp/tests/local_trial_workflows/l0_solar_gain_as_science.py +387 -0
  40. dkist_processing_visp/tests/local_trial_workflows/l0_to_l1.py +18 -75
  41. dkist_processing_visp/tests/local_trial_workflows/local_trial_helpers.py +346 -14
  42. dkist_processing_visp/tests/test_assemble_movie.py +2 -3
  43. dkist_processing_visp/tests/test_assemble_quality.py +89 -4
  44. dkist_processing_visp/tests/test_background_light.py +51 -44
  45. dkist_processing_visp/tests/test_dark.py +4 -3
  46. dkist_processing_visp/tests/test_downsample.py +1 -0
  47. dkist_processing_visp/tests/test_fits_access.py +43 -0
  48. dkist_processing_visp/tests/test_geometric.py +45 -4
  49. dkist_processing_visp/tests/test_instrument_polarization.py +72 -9
  50. dkist_processing_visp/tests/test_lamp.py +22 -26
  51. dkist_processing_visp/tests/test_make_movie_frames.py +4 -4
  52. dkist_processing_visp/tests/test_map_repeats.py +3 -1
  53. dkist_processing_visp/tests/test_parameters.py +122 -21
  54. dkist_processing_visp/tests/test_parse.py +164 -18
  55. dkist_processing_visp/tests/test_quality.py +3 -4
  56. dkist_processing_visp/tests/test_science.py +113 -15
  57. dkist_processing_visp/tests/test_solar.py +318 -99
  58. dkist_processing_visp/tests/test_visp_constants.py +38 -8
  59. dkist_processing_visp/tests/test_workflows.py +1 -0
  60. dkist_processing_visp/tests/test_write_l1.py +22 -3
  61. dkist_processing_visp/workflows/__init__.py +1 -0
  62. dkist_processing_visp/workflows/l0_processing.py +10 -3
  63. dkist_processing_visp/workflows/trial_workflows.py +8 -2
  64. dkist_processing_visp-5.1.1.dist-info/METADATA +552 -0
  65. dkist_processing_visp-5.1.1.dist-info/RECORD +94 -0
  66. {dkist_processing_visp-2.20.14.dist-info → dkist_processing_visp-5.1.1.dist-info}/WHEEL +1 -1
  67. docs/conf.py +5 -1
  68. docs/gain_correction.rst +52 -44
  69. docs/science_calibration.rst +7 -0
  70. dkist_processing_visp/tasks/mixin/line_zones.py +0 -115
  71. dkist_processing_visp-2.20.14.dist-info/METADATA +0 -196
  72. dkist_processing_visp-2.20.14.dist-info/RECORD +0 -89
  73. {dkist_processing_visp-2.20.14.dist-info → dkist_processing_visp-5.1.1.dist-info}/top_level.txt +0 -0
@@ -1,4 +1,5 @@
1
1
  """ViSP instrument polarization task. See :doc:`this page </polarization_calibration>` for more information."""
2
+
2
3
  from collections import defaultdict
3
4
 
4
5
  import numpy as np
@@ -18,8 +19,8 @@ from dkist_processing_pac.input_data.drawer import Drawer
18
19
  from dkist_processing_pac.input_data.dresser import Dresser
19
20
  from dkist_service_configuration.logging import logger
20
21
  from sklearn.linear_model import RANSACRegressor
21
- from sklearn.pipeline import make_pipeline
22
22
  from sklearn.pipeline import Pipeline
23
+ from sklearn.pipeline import make_pipeline
23
24
  from sklearn.preprocessing import PolynomialFeatures
24
25
  from sklearn.preprocessing import RobustScaler
25
26
 
@@ -72,6 +73,15 @@ class InstrumentPolarizationCalibration(
72
73
  if not self.constants.correct_for_polarization:
73
74
  return
74
75
 
76
+ polcal_readout_exposure_times = self.constants.polcal_readout_exp_times
77
+ if len(polcal_readout_exposure_times) > 1:
78
+ logger.info(
79
+ "WARNING: More than one polcal readout exposure time detected. "
80
+ "Everything *should* still work, but this is a weird condition that may produce "
81
+ "strange results."
82
+ )
83
+ logger.info(f"{polcal_readout_exposure_times = }")
84
+
75
85
  # Process the pol cal frames
76
86
  logger.info(
77
87
  f"Demodulation matrices will span FOV with shape 1 spectral bin "
@@ -79,10 +89,22 @@ class InstrumentPolarizationCalibration(
79
89
  )
80
90
  remove_I_trend = self.parameters.pac_remove_linear_I_trend
81
91
  for beam in range(1, self.constants.num_beams + 1):
82
- with self.apm_processing_step(f"Reducing CS steps for {beam = }"):
92
+ with self.telemetry_span("Generate polcal DARK frame"):
93
+ logger.info("Generating polcal dark frame")
94
+ self.generate_polcal_dark_calibration(
95
+ readout_exp_times=polcal_readout_exposure_times, beam=beam
96
+ )
97
+
98
+ with self.telemetry_span("Generate polcal GAIN frame"):
99
+ logger.info("Generating polcal gain frame")
100
+ self.generate_polcal_gain_calibration(
101
+ readout_exp_times=polcal_readout_exposure_times, beam=beam
102
+ )
103
+
104
+ with self.telemetry_span(f"Reducing CS steps for {beam = }"):
83
105
  local_reduced_arrays, global_reduced_arrays = self.reduce_cs_steps(beam)
84
106
 
85
- with self.apm_processing_step(f"Fit CU parameters for {beam = }"):
107
+ with self.telemetry_span(f"Fit CU parameters for {beam = }"):
86
108
  local_dresser = Dresser()
87
109
  local_dresser.add_drawer(
88
110
  Drawer(local_reduced_arrays, remove_I_trend=remove_I_trend)
@@ -95,13 +117,13 @@ class InstrumentPolarizationCalibration(
95
117
  local_dresser=local_dresser,
96
118
  global_dresser=global_dresser,
97
119
  fit_mode=self.parameters.pac_fit_mode,
98
- init_set=self.parameters.pac_init_set,
120
+ init_set=self.constants.pac_init_set,
99
121
  inherit_global_vary_in_local_fit=True,
100
122
  suppress_local_starting_values=True,
101
123
  fit_TM=False,
102
124
  )
103
125
 
104
- with self.apm_processing_step(f"Resampling demodulation matrices for {beam = }"):
126
+ with self.telemetry_span(f"Resampling demodulation matrices for {beam = }"):
105
127
  demod_matrices = pac_fitter.demodulation_matrices
106
128
 
107
129
  self.write(
@@ -127,7 +149,7 @@ class InstrumentPolarizationCalibration(
127
149
  demod_matrices = self.reshape_demod_matrices(smoothed_demod)
128
150
  logger.info(f"Shape of resampled demodulation matrices: {demod_matrices.shape}")
129
151
 
130
- with self.apm_writing_step(f"Writing demodulation matrices for {beam = }"):
152
+ with self.telemetry_span(f"Writing demodulation matrices for {beam = }"):
131
153
  # Save the demod matrices as intermediate products
132
154
  self.write(
133
155
  data=demod_matrices,
@@ -138,10 +160,10 @@ class InstrumentPolarizationCalibration(
138
160
  encoder=fits_array_encoder,
139
161
  )
140
162
 
141
- with self.apm_processing_step("Computing and recording polcal quality metrics"):
163
+ with self.telemetry_span("Computing and recording polcal quality metrics"):
142
164
  self.record_polcal_quality_metrics(beam, polcal_fitter=pac_fitter)
143
165
 
144
- with self.apm_processing_step("Computing and logging quality metrics"):
166
+ with self.telemetry_span("Computing and logging quality metrics"):
145
167
  no_of_raw_polcal_frames: int = self.scratch.count_all(
146
168
  tags=[
147
169
  VispTag.input(),
@@ -158,7 +180,7 @@ class InstrumentPolarizationCalibration(
158
180
  self, beam: int
159
181
  ) -> tuple[dict[int, list[VispL0FitsAccess]], dict[int, list[VispL0FitsAccess]]]:
160
182
  """
161
- Reduce all of the data for the cal sequence steps for this beam.
183
+ Reduce all the data for the cal sequence steps for this beam.
162
184
 
163
185
  Parameters
164
186
  ----------
@@ -215,10 +237,10 @@ class InstrumentPolarizationCalibration(
215
237
 
216
238
  for readout_exp_time in self.constants.polcal_readout_exp_times:
217
239
  # Put this loop here because the geometric objects will be constant across exposure times
218
- logger.info(f"Loading dark for {readout_exp_time = } and {beam = }")
240
+ logger.info(f"Loading polcal dark for {beam = }")
219
241
  dark_array = next(
220
242
  self.read(
221
- tags=VispTag.intermediate_frame_dark(
243
+ tags=VispTag.intermediate_frame_polcal_dark(
222
244
  beam=beam, readout_exp_time=readout_exp_time
223
245
  ),
224
246
  decoder=fits_array_decoder,
@@ -318,24 +340,25 @@ class InstrumentPolarizationCalibration(
318
340
  )
319
341
  avg_inst_pol_cal_array = average_numpy_arrays(readout_normalized_arrays)
320
342
 
321
- with self.apm_processing_step(f"Apply basic corrections for {apm_str}"):
343
+ with self.telemetry_span(f"Apply basic corrections for {apm_str}"):
322
344
  dark_corrected_array = subtract_array_from_arrays(avg_inst_pol_cal_array, dark_array)
323
345
 
324
346
  background_corrected_array = subtract_array_from_arrays(
325
347
  dark_corrected_array, background_array
326
348
  )
327
349
 
328
- solar_gain_array = next(
350
+ polcal_gain_array = next(
329
351
  self.read(
330
352
  tags=[
331
- VispTag.intermediate_frame(beam=beam, modstate=modstate),
332
- VispTag.task_solar_gain(),
353
+ VispTag.intermediate_frame_polcal_gain(
354
+ beam=beam, readout_exp_time=readout_exp_time
355
+ ),
333
356
  ],
334
357
  decoder=fits_array_decoder,
335
358
  )
336
359
  )
337
360
  gain_corrected_array = next(
338
- divide_arrays_by_array(background_corrected_array, solar_gain_array)
361
+ divide_arrays_by_array(background_corrected_array, polcal_gain_array)
339
362
  )
340
363
 
341
364
  geo_corrected_array = self.corrections_correct_geometry(
@@ -346,7 +369,7 @@ class InstrumentPolarizationCalibration(
346
369
  self.corrections_remove_spec_geometry(geo_corrected_array, spec_shift)
347
370
  )
348
371
 
349
- with self.apm_processing_step(f"Extract macro pixels from {apm_str}"):
372
+ with self.telemetry_span(f"Extract macro pixels from {apm_str}"):
350
373
  self.set_original_beam_size(gain_corrected_array)
351
374
  filtered_array = self.corrections_mask_hairlines(spectral_corrected_array)
352
375
 
@@ -366,7 +389,7 @@ class InstrumentPolarizationCalibration(
366
389
  # Add two dummy dimensions just to keep it 2D.
367
390
  global_binned_array = np.nanmedian(filtered_array)[None, None]
368
391
 
369
- with self.apm_processing_step(f"Create reduced VispL0FitsAccess for {apm_str}"):
392
+ with self.telemetry_span(f"Create reduced VispL0FitsAccess for {apm_str}"):
370
393
  local_result = VispL0FitsAccess(
371
394
  fits.ImageHDU(local_array, avg_inst_pol_cal_header),
372
395
  auto_squeeze=False,
@@ -527,3 +550,90 @@ class InstrumentPolarizationCalibration(
527
550
  return next(
528
551
  resize_arrays(array, output_shape, order=self.parameters.polcal_demod_upsample_order)
529
552
  )
553
+
554
+ def generate_polcal_dark_calibration(
555
+ self, readout_exp_times: list[float] | tuple[float], beam: int
556
+ ) -> None:
557
+ """Compute an average polcal dark array for all polcal exposure times."""
558
+ for readout_exp_time in readout_exp_times:
559
+ logger.info(f"Computing polcal dark for {readout_exp_time = }")
560
+
561
+ dark_arrays = self.read(
562
+ tags=[
563
+ VispTag.input(),
564
+ VispTag.frame(),
565
+ VispTag.task_polcal_dark(),
566
+ VispTag.readout_exp_time(readout_exp_time),
567
+ ],
568
+ decoder=fits_access_decoder,
569
+ fits_access_class=VispL0FitsAccess,
570
+ )
571
+
572
+ readout_normalized_arrays = (
573
+ self.beam_access_get_beam(o.data, beam=beam) / o.num_raw_frames_per_fpa
574
+ for o in dark_arrays
575
+ )
576
+
577
+ avg_array = average_numpy_arrays(readout_normalized_arrays)
578
+ self.write(
579
+ data=avg_array,
580
+ tags=[
581
+ VispTag.intermediate_frame_polcal_dark(
582
+ beam=beam, readout_exp_time=readout_exp_time
583
+ ),
584
+ ],
585
+ encoder=fits_array_encoder,
586
+ )
587
+
588
+ def generate_polcal_gain_calibration(
589
+ self, readout_exp_times: list[float] | tuple[float], beam: int
590
+ ) -> None:
591
+ """
592
+ Average 'clear' polcal frames to produce a polcal gain calibration.
593
+
594
+ The polcal dark calibration is applied prior to averaging.
595
+ """
596
+ for readout_exp_time in readout_exp_times:
597
+ logger.info(f"Computing polcal gain for {readout_exp_time = }")
598
+
599
+ dark_array = next(
600
+ self.read(
601
+ tags=[
602
+ VispTag.intermediate_frame(beam=beam),
603
+ VispTag.task_polcal_dark(),
604
+ VispTag.readout_exp_time(readout_exp_time),
605
+ ],
606
+ decoder=fits_array_decoder,
607
+ )
608
+ )
609
+
610
+ gain_arrays = self.read(
611
+ tags=[
612
+ VispTag.input(),
613
+ VispTag.frame(),
614
+ VispTag.task_polcal_gain(),
615
+ VispTag.readout_exp_time(readout_exp_time),
616
+ ],
617
+ decoder=fits_access_decoder,
618
+ fits_access_class=VispL0FitsAccess,
619
+ )
620
+
621
+ readout_normalized_arrays = (
622
+ self.beam_access_get_beam(o.data, beam=beam) / o.num_raw_frames_per_fpa
623
+ for o in gain_arrays
624
+ )
625
+
626
+ dark_corrected_arrays = subtract_array_from_arrays(
627
+ arrays=readout_normalized_arrays, array_to_subtract=dark_array
628
+ )
629
+
630
+ avg_array = average_numpy_arrays(dark_corrected_arrays)
631
+ self.write(
632
+ data=avg_array,
633
+ tags=[
634
+ VispTag.intermediate_frame_polcal_gain(
635
+ beam=beam, readout_exp_time=readout_exp_time
636
+ ),
637
+ ],
638
+ encoder=fits_array_encoder,
639
+ )
@@ -1,13 +1,216 @@
1
1
  """Subclass of AssembleQualityData that causes the correct polcal metrics to build."""
2
+
3
+ import numpy as np
4
+ from dkist_processing_common.codecs.asdf import asdf_decoder
5
+ from dkist_processing_common.models.quality import Plot2D
6
+ from dkist_processing_common.models.quality import ReportMetric
7
+ from dkist_processing_common.models.quality import VerticalMultiPanePlot2D
2
8
  from dkist_processing_common.tasks import AssembleQualityData
3
9
 
4
10
  __all__ = ["VispAssembleQualityData"]
5
11
 
12
+ from dkist_processing_visp.models.constants import VispConstants
13
+ from dkist_processing_visp.models.metric_code import VispMetricCode
14
+ from dkist_processing_visp.models.tags import VispTag
15
+
6
16
 
7
17
  class VispAssembleQualityData(AssembleQualityData):
8
18
  """Subclass just so that the polcal_label_list can be populated."""
9
19
 
20
+ constants: VispConstants
21
+
22
+ @property
23
+ def constants_model_class(self):
24
+ """Get ViSP pipeline constants."""
25
+ return VispConstants
26
+
10
27
  @property
11
28
  def polcal_label_list(self) -> list[str]:
12
29
  """Return labels for beams 1 and 2."""
13
30
  return ["Beam 1", "Beam 2"]
31
+
32
+ def quality_assemble_data(self, polcal_label_list: list[str] | None = None) -> list[dict]:
33
+ """
34
+ Assemble the full quality report and insert ViSP-specific metrics.
35
+
36
+ We try to place the new metrics right before default polcal ones, if possible.
37
+ """
38
+ vignette_metrics = []
39
+ for beam in range(1, self.constants.num_beams + 1):
40
+ vignette_metrics.append(self.build_first_vignette_metric(beam=beam))
41
+ vignette_metrics.append(self.build_final_vignette_metric(beam=beam))
42
+
43
+ report = super().quality_assemble_data(polcal_label_list=polcal_label_list)
44
+
45
+ # Look for the first "PolCal" metric
46
+ first_polcal_metric_index = 0
47
+ try:
48
+ while not report[first_polcal_metric_index]["name"].lower().startswith("polcal"):
49
+ first_polcal_metric_index += 1
50
+ except:
51
+ # Wasn't found for whatever reason. No big deal, just put the new metrics at the front of the list
52
+ first_polcal_metric_index = 0
53
+
54
+ final_report = (
55
+ report[:first_polcal_metric_index]
56
+ + vignette_metrics
57
+ + report[first_polcal_metric_index:]
58
+ )
59
+
60
+ return final_report
61
+
62
+ def build_first_vignette_metric(self, beam: int) -> dict:
63
+ """Build a ReportMetric showing the initial atlas-with-continuum fit and residuals."""
64
+ data = next(
65
+ self.read(
66
+ tags=[VispTag.quality(VispMetricCode.solar_first_vignette), VispTag.beam(beam)],
67
+ decoder=asdf_decoder,
68
+ )
69
+ )
70
+
71
+ wave_vec = data["output_wave_vec"].tolist()
72
+ input_spectrum = data["input_spectrum"].tolist()
73
+ best_fit_atlas = data["best_fit_atlas"].tolist()
74
+ continuum = data["best_fit_continuum"].tolist()
75
+ residuals = data["residuals"].tolist()
76
+
77
+ fit_series = {
78
+ "Raw input spectrum": [wave_vec, input_spectrum],
79
+ "Best fit atlas": [wave_vec, best_fit_atlas],
80
+ "Best fit continuum": [wave_vec, continuum],
81
+ }
82
+
83
+ fit_plot_kwargs = {
84
+ "Raw input spectrum": {
85
+ "ls": "-",
86
+ "ms": 0,
87
+ "color": "#FAA61C",
88
+ "zorder": 2.0,
89
+ "lw": 4,
90
+ "alpha": 0.6,
91
+ },
92
+ "Best fit atlas": {"color": "k", "ls": "-", "ms": 0, "zorder": 2.1},
93
+ "Best fit continuum": {"ls": "-", "ms": 0, "color": "g", "zorder": 2.2},
94
+ }
95
+
96
+ fit_plot = Plot2D(
97
+ xlabel="Wavelength [nm]",
98
+ ylabel="Signal",
99
+ series_data=fit_series,
100
+ plot_kwargs=fit_plot_kwargs,
101
+ sort_series=False,
102
+ )
103
+
104
+ residuals_series = {"Residuals": [wave_vec, residuals]}
105
+ residuals_plot_kwargs = {"Residuals": {"ls": "-", "color": "k", "ms": 0}}
106
+
107
+ y_min = np.nanpercentile(residuals, 2)
108
+ y_max = np.nanpercentile(residuals, 98)
109
+ y_range = y_max - y_min
110
+ y_min -= 0.1 * y_range
111
+ y_max += 0.1 * y_range
112
+ residuals_plot = Plot2D(
113
+ xlabel="Wavelength [nm]",
114
+ ylabel=r"$\frac{\mathrm{Obs - Atlas}}{\mathrm{Obs}}$",
115
+ series_data=residuals_series,
116
+ plot_kwargs=residuals_plot_kwargs,
117
+ ylim=(y_min, y_max),
118
+ )
119
+
120
+ plot_list = [fit_plot, residuals_plot]
121
+ height_ratios = [1.5, 1.0]
122
+
123
+ full_plot = VerticalMultiPanePlot2D(
124
+ top_to_bottom_plot_list=plot_list,
125
+ match_x_axes=True,
126
+ no_gap=True,
127
+ top_to_bottom_height_ratios=height_ratios,
128
+ )
129
+
130
+ metric = ReportMetric(
131
+ name=f"Initial Vignette Estimation - Beam {beam}",
132
+ description="These plots show the solar atlas fit used to estimate the initial, 1D spectral vignette "
133
+ "present in solar gain frames. The vignette signature is taken to be the fit continuum shown.",
134
+ metric_code=VispMetricCode.solar_first_vignette,
135
+ facet=self._format_facet(f"Beam {beam}"),
136
+ multi_plot_data=full_plot,
137
+ )
138
+
139
+ return metric.model_dump()
140
+
141
+ def build_final_vignette_metric(self, beam: int) -> dict:
142
+ """Build a ReportMetric showing the quality of the vignette correction on solar gain data."""
143
+ data = next(
144
+ self.read(
145
+ tags=[VispTag.quality(VispMetricCode.solar_final_vignette), VispTag.beam(beam)],
146
+ decoder=asdf_decoder,
147
+ )
148
+ )
149
+
150
+ wave_vec = data["output_wave_vec"].tolist()
151
+ median_spec = data["median_spec"].tolist()
152
+ low_deviation = data["low_deviation"]
153
+ high_deviation = data["high_deviation"]
154
+ diff = (high_deviation - low_deviation).tolist()
155
+ low_deviation = low_deviation.tolist()
156
+ high_deviation = high_deviation.tolist()
157
+
158
+ bounds_series = {
159
+ "Median solar signal": [wave_vec, median_spec],
160
+ "5th percentile bounds": [wave_vec, low_deviation],
161
+ "95th percentile bounds": [wave_vec, high_deviation],
162
+ }
163
+
164
+ bounds_plot_kwargs = {
165
+ "Median solar signal": {"ls": "-", "color": "k", "alpha": 0.8, "ms": 0, "zorder": 2.2},
166
+ "5th percentile bounds": {"color": "#1E317A", "ls": "-", "ms": 0, "zorder": 2.0},
167
+ "95th percentile bounds": {"ls": "-", "color": "#FAA61C", "ms": 0, "zorder": 2.1},
168
+ }
169
+
170
+ bounds_plot = Plot2D(
171
+ xlabel="Wavelength [nm]",
172
+ ylabel="Signal",
173
+ series_data=bounds_series,
174
+ plot_kwargs=bounds_plot_kwargs,
175
+ sort_series=False,
176
+ )
177
+
178
+ residuals_series = {"Residuals": [wave_vec, diff]}
179
+ residuals_plot_kwargs = {"Residuals": {"ls": "-", "color": "k", "ms": 0}}
180
+
181
+ y_min = np.nanpercentile(diff, 5)
182
+ y_max = np.nanpercentile(diff, 95)
183
+ y_range = y_max - y_min
184
+ y_min -= 0.1 * y_range
185
+ y_max += 0.1 * y_range
186
+ residuals_plot = Plot2D(
187
+ xlabel="Wavelength [nm]",
188
+ ylabel="95th - 5th percentile",
189
+ series_data=residuals_series,
190
+ plot_kwargs=residuals_plot_kwargs,
191
+ ylim=(y_min, y_max),
192
+ )
193
+
194
+ plot_list = [bounds_plot, residuals_plot]
195
+ height_ratios = [1.5, 1.0]
196
+
197
+ full_plot = VerticalMultiPanePlot2D(
198
+ top_to_bottom_plot_list=plot_list,
199
+ match_x_axes=True,
200
+ no_gap=True,
201
+ top_to_bottom_height_ratios=height_ratios,
202
+ )
203
+
204
+ metric = ReportMetric(
205
+ name=f"Final Vignette Estimation - Beam {beam}",
206
+ description="These plots show how well the full, 2D vignette signal was removed from solar gain frames. "
207
+ "The median solar signal shows a full spatial median of the vignette corrected solar gain; "
208
+ "this should be very close to the true solar spectrum incident on the DKIST optics. "
209
+ "The 5th and 9th percentile ranges show how stable this spectrum is along the spatial dimension "
210
+ "after removing the vignette signal.",
211
+ metric_code=VispMetricCode.solar_final_vignette,
212
+ facet=self._format_facet(f"Beam {beam}"),
213
+ multi_plot_data=full_plot,
214
+ )
215
+
216
+ return metric.model_dump()
@@ -1,5 +1,5 @@
1
1
  """ViSP lamp calibration task. See :doc:`this page </gain_correction>` for more information."""
2
- import numpy as np
2
+
3
3
  from dkist_processing_common.codecs.fits import fits_access_decoder
4
4
  from dkist_processing_common.codecs.fits import fits_array_decoder
5
5
  from dkist_processing_common.codecs.fits import fits_array_encoder
@@ -29,13 +29,14 @@ class LampCalibration(
29
29
 
30
30
  Parameters
31
31
  ----------
32
- recipe_run_id : int
32
+ recipe_run_id
33
33
  id of the recipe run used to identify the workflow run this task is part of
34
- workflow_name : str
34
+
35
+ workflow_name
35
36
  name of the workflow to which this instance of the task belongs
36
- workflow_version : str
37
- version of the workflow to which this instance of the task belongs
38
37
 
38
+ workflow_version
39
+ version of the workflow to which this instance of the task belongs
39
40
  """
40
41
 
41
42
  record_provenance = True
@@ -44,9 +45,11 @@ class LampCalibration(
44
45
  """
45
46
  For each beam.
46
47
 
47
- - Gather input lamp gain and averaged dark arrays
48
- - Calculate master lamp
49
- - Write master lamp
48
+ - Normalize all input arrays by the number of frames per FPA
49
+ - Subtract the average dark frame corresponding to the matching readout exposure time
50
+ - Average all different readout exposure time arrays (if applicable)
51
+ - Interpolate over the hairlines
52
+ - Write final lamp gain to disk
50
53
  - Record quality metrics
51
54
 
52
55
  Returns
@@ -54,12 +57,14 @@ class LampCalibration(
54
57
  None
55
58
 
56
59
  """
57
- with self.apm_task_step(
60
+ with self.telemetry_span(
58
61
  f"Generate lamp gains for {self.constants.num_beams} beams and {len(self.constants.lamp_readout_exp_times)} exposure times"
59
62
  ):
60
- for readout_exp_time in self.constants.lamp_readout_exp_times:
61
- for beam in range(1, self.constants.num_beams + 1):
62
- logger.info(f"Load dark for beam {beam}")
63
+ for beam in range(1, self.constants.num_beams + 1):
64
+ all_exp_time_arrays = []
65
+ for readout_exp_time in self.constants.lamp_readout_exp_times:
66
+ apm_str = f"{beam = } and {readout_exp_time = }"
67
+ logger.info(f"Load dark for beam {apm_str}")
63
68
  dark_array = next(
64
69
  self.read(
65
70
  tags=VispTag.intermediate_frame_dark(
@@ -69,20 +74,45 @@ class LampCalibration(
69
74
  )
70
75
  )
71
76
 
72
- for state_num in range(
73
- 1, self.constants.num_modstates + 1
74
- ): # modulator states go from 1 to n
75
- logger.info(
76
- f"Calculating average lamp gain for beam {beam}, modulator state {state_num}"
77
+ with self.telemetry_span(f"Computing gain for {apm_str}"):
78
+ tags = [
79
+ VispTag.input(),
80
+ VispTag.frame(),
81
+ VispTag.task_lamp_gain(),
82
+ VispTag.readout_exp_time(readout_exp_time),
83
+ ]
84
+ input_lamp_gain_objs = self.read(
85
+ tags=tags,
86
+ decoder=fits_access_decoder,
87
+ fits_access_class=VispL0FitsAccess,
77
88
  )
78
- self.compute_and_write_master_lamp_gain_for_modstate(
79
- modstate=state_num,
80
- dark_array=dark_array,
81
- beam=beam,
82
- readout_exp_time=readout_exp_time,
89
+
90
+ readout_normalized_arrays = (
91
+ self.beam_access_get_beam(o.data, beam=beam) / o.num_raw_frames_per_fpa
92
+ for o in input_lamp_gain_objs
93
+ )
94
+ averaged_gain_data = average_numpy_arrays(readout_normalized_arrays)
95
+
96
+ dark_corrected_gain_data = next(
97
+ subtract_array_from_arrays(averaged_gain_data, dark_array)
83
98
  )
84
99
 
85
- with self.apm_processing_step("Computing and logging quality metrics"):
100
+ all_exp_time_arrays.append(dark_corrected_gain_data)
101
+
102
+ avg_gain_array = average_numpy_arrays(all_exp_time_arrays)
103
+ filtered_gain_data = self.corrections_mask_hairlines(avg_gain_array)
104
+
105
+ with self.telemetry_span(f"Writing gain array for {apm_str}"):
106
+ self.write(
107
+ data=filtered_gain_data,
108
+ tags=[
109
+ VispTag.intermediate_frame(beam=beam),
110
+ VispTag.task_lamp_gain(),
111
+ ],
112
+ encoder=fits_array_encoder,
113
+ )
114
+
115
+ with self.telemetry_span("Computing and logging quality metrics"):
86
116
  no_of_raw_lamp_frames: int = self.scratch.count_all(
87
117
  tags=[
88
118
  VispTag.input(),
@@ -94,73 +124,3 @@ class LampCalibration(
94
124
  self.quality_store_task_type_counts(
95
125
  task_type=TaskName.lamp_gain.value, total_frames=no_of_raw_lamp_frames
96
126
  )
97
-
98
- def compute_and_write_master_lamp_gain_for_modstate(
99
- self,
100
- modstate: int,
101
- dark_array: np.ndarray,
102
- beam: int,
103
- readout_exp_time: float,
104
- ) -> None:
105
- """
106
- Compute and write master lamp gain for a given modstate and beam.
107
-
108
- Generally the algorithm is:
109
- 1. Average input gain arrays
110
- 2. Subtract average dark to get the dark corrected gain data
111
- 3. Normalize each beam to unity mean
112
- 4. Write to disk
113
-
114
- Parameters
115
- ----------
116
- modstate : int
117
- The modulator state to calculate the master lamp gain for
118
-
119
- dark_array : np.ndarray
120
- The master dark to be subtracted from each lamp gain file
121
-
122
- beam : int
123
- The number of the beam
124
-
125
- readout_exp_time : float
126
- Exposure time of single readout
127
-
128
- Returns
129
- -------
130
- None
131
- """
132
- apm_str = f"{beam = }, {modstate = }, and {readout_exp_time = }"
133
- # Get the input lamp gain arrays
134
- tags = [
135
- VispTag.input(),
136
- VispTag.frame(),
137
- VispTag.task_lamp_gain(),
138
- VispTag.modstate(modstate),
139
- VispTag.readout_exp_time(readout_exp_time),
140
- ]
141
- input_lamp_gain_objs = self.read(
142
- tags=tags, decoder=fits_access_decoder, fits_access_class=VispL0FitsAccess
143
- )
144
-
145
- with self.apm_processing_step(f"Computing gain for {apm_str}"):
146
-
147
- readout_normalized_arrays = (
148
- self.beam_access_get_beam(o.data, beam=beam) / o.num_raw_frames_per_fpa
149
- for o in input_lamp_gain_objs
150
- )
151
- averaged_gain_data = average_numpy_arrays(readout_normalized_arrays)
152
-
153
- dark_corrected_gain_data = next(
154
- subtract_array_from_arrays(averaged_gain_data, dark_array)
155
- )
156
- filtered_gain_data = self.corrections_mask_hairlines(dark_corrected_gain_data)
157
-
158
- with self.apm_writing_step(f"Writing gain array for {apm_str}"):
159
- self.write(
160
- data=filtered_gain_data,
161
- tags=[
162
- VispTag.intermediate_frame(beam=beam, modstate=modstate),
163
- VispTag.task_lamp_gain(),
164
- ],
165
- encoder=fits_array_encoder,
166
- )