dkist-processing-trend 0.1.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 (66) hide show
  1. changelog/.gitempty +0 -0
  2. dkist_processing_trend/__init__.py +10 -0
  3. dkist_processing_trend/config.py +11 -0
  4. dkist_processing_trend/models/__init__.py +1 -0
  5. dkist_processing_trend/models/constants.py +143 -0
  6. dkist_processing_trend/models/fit_options.py +15 -0
  7. dkist_processing_trend/models/fits_access.py +65 -0
  8. dkist_processing_trend/models/instrument.py +35 -0
  9. dkist_processing_trend/models/instrument_options.py +35 -0
  10. dkist_processing_trend/models/parameters.py +134 -0
  11. dkist_processing_trend/models/tags.py +117 -0
  12. dkist_processing_trend/models/task_name.py +20 -0
  13. dkist_processing_trend/parsers/__init__.py +1 -0
  14. dkist_processing_trend/parsers/arm_id.py +103 -0
  15. dkist_processing_trend/parsers/instrument_unique_bud.py +40 -0
  16. dkist_processing_trend/parsers/time.py +27 -0
  17. dkist_processing_trend/parsers/trend_l0_fits_access.py +123 -0
  18. dkist_processing_trend/tasks/__init__.py +27 -0
  19. dkist_processing_trend/tasks/arm_task_factory.py +57 -0
  20. dkist_processing_trend/tasks/dark.py +56 -0
  21. dkist_processing_trend/tasks/gain.py +74 -0
  22. dkist_processing_trend/tasks/initialize_arm_tasks.py +50 -0
  23. dkist_processing_trend/tasks/parse.py +169 -0
  24. dkist_processing_trend/tasks/prepare_fit_data_base.py +257 -0
  25. dkist_processing_trend/tasks/run_pac_fitter.py +392 -0
  26. dkist_processing_trend/tasks/trend_base.py +97 -0
  27. dkist_processing_trend/tasks/trend_output_data.py +167 -0
  28. dkist_processing_trend/tasks/visp/__init__.py +6 -0
  29. dkist_processing_trend/tasks/visp/visp_dmpd.py +410 -0
  30. dkist_processing_trend/tasks/visp/visp_extract_beam.py +14 -0
  31. dkist_processing_trend/tasks/visp/visp_geometric.py +260 -0
  32. dkist_processing_trend/tasks/visp/visp_prep_fit_data.py +162 -0
  33. dkist_processing_trend/tasks/visp/visp_process_demod.py +236 -0
  34. dkist_processing_trend/tasks/write_trend.py +663 -0
  35. dkist_processing_trend/tests/__init__.py +1 -0
  36. dkist_processing_trend/tests/conftest.py +718 -0
  37. dkist_processing_trend/tests/local_trial_workflows/__init__.py +0 -0
  38. dkist_processing_trend/tests/local_trial_workflows/l0_to_trend_visp_polcal.py +294 -0
  39. dkist_processing_trend/tests/local_trial_workflows/local_trial_helpers.py +488 -0
  40. dkist_processing_trend/tests/test_arm_task_factory.py +82 -0
  41. dkist_processing_trend/tests/test_base_tasks.py +86 -0
  42. dkist_processing_trend/tests/test_constants.py +120 -0
  43. dkist_processing_trend/tests/test_dark.py +97 -0
  44. dkist_processing_trend/tests/test_gain.py +135 -0
  45. dkist_processing_trend/tests/test_parameters.py +149 -0
  46. dkist_processing_trend/tests/test_parse.py +276 -0
  47. dkist_processing_trend/tests/test_prep_fit_data_base.py +233 -0
  48. dkist_processing_trend/tests/test_publish_catalog_messages.py +45 -0
  49. dkist_processing_trend/tests/test_run_pac_fitter.py +371 -0
  50. dkist_processing_trend/tests/test_stems.py +75 -0
  51. dkist_processing_trend/tests/test_transfer_output_data.py +76 -0
  52. dkist_processing_trend/tests/test_trend_fits_access.py +173 -0
  53. dkist_processing_trend/tests/test_visp.py +874 -0
  54. dkist_processing_trend/tests/test_workflows.py +10 -0
  55. dkist_processing_trend/tests/test_write_trend.py +460 -0
  56. dkist_processing_trend/workflows/__init__.py +3 -0
  57. dkist_processing_trend/workflows/visp.py +58 -0
  58. dkist_processing_trend-0.1.0.dist-info/METADATA +549 -0
  59. dkist_processing_trend-0.1.0.dist-info/RECORD +66 -0
  60. dkist_processing_trend-0.1.0.dist-info/WHEEL +5 -0
  61. dkist_processing_trend-0.1.0.dist-info/top_level.txt +3 -0
  62. docs/conf.py +57 -0
  63. docs/index.rst +10 -0
  64. docs/l0_to_trend_visp_polcal.rst +4 -0
  65. docs/landing_page.rst +11 -0
  66. docs/requirements_table.rst +8 -0
@@ -0,0 +1,663 @@
1
+ """Task class for writing trend output files."""
2
+
3
+ import uuid
4
+ from typing import Type
5
+
6
+ import numpy as np
7
+ from astropy.io import fits
8
+ from astropy.time import Time
9
+ from dkist_fits_specifications import __version__ as spec_version
10
+ from dkist_fits_specifications.utils.formatter import reformat_trend_header
11
+ from dkist_processing_common.codecs.fits import fits_array_decoder
12
+ from dkist_processing_common.codecs.fits import fits_hdulist_encoder
13
+ from dkist_processing_common.tasks.write_l1_base import WriteL1Base
14
+ from dkist_processing_pac.fitter.fitter_parameters import PolcalDresserParameters
15
+
16
+ from dkist_processing_trend.models.constants import TrendConstants
17
+ from dkist_processing_trend.models.fit_options import FitOptions
18
+ from dkist_processing_trend.models.instrument import InstrumentName
19
+ from dkist_processing_trend.models.instrument_options import CryonirspInstrumentOptions
20
+ from dkist_processing_trend.models.instrument_options import DlnirspInstrumentOptions
21
+ from dkist_processing_trend.models.instrument_options import InstrumentOptionsBase
22
+ from dkist_processing_trend.models.instrument_options import VispInstrumentOptions
23
+ from dkist_processing_trend.models.parameters import TrendParameters
24
+ from dkist_processing_trend.models.tags import TrendTag
25
+
26
+ __all__ = ["WriteTrendOutputs"]
27
+
28
+
29
+ class WriteTrendOutputs(WriteL1Base):
30
+ """Task for writing trend output files.
31
+
32
+ Parameters
33
+ ----------
34
+ recipe_run_id
35
+ id of the recipe run used to identify the workflow run this task is part of
36
+
37
+ workflow_name
38
+ name of the workflow to which this instance of the task belongs
39
+
40
+ workflow_version
41
+ version of the workflow to which this instance of the task belongs
42
+ """
43
+
44
+ constants: TrendConstants
45
+
46
+ @property
47
+ def constants_model_class(self) -> Type[TrendConstants]:
48
+ """Return the constants model class used by this class."""
49
+ return TrendConstants
50
+
51
+ def __init__(self, recipe_run_id: int, workflow_name: str, workflow_version: str):
52
+ super().__init__(
53
+ recipe_run_id=recipe_run_id,
54
+ workflow_name=workflow_name,
55
+ workflow_version=workflow_version,
56
+ )
57
+ self.parameters = TrendParameters(
58
+ scratch=self.scratch,
59
+ obs_ip_start_time=self.constants.earliest_ip_start_time,
60
+ instrument=self.constants.instrument,
61
+ )
62
+
63
+ def run(self):
64
+ """Produce a single trend output file for each combination of arm ID, pac fit options, and instrument fit options."""
65
+ for arm_id in self.constants.arm_id_list:
66
+ # HDUs that are the same for all instrument/fit options
67
+ calibration_sequence_hdu = self.generate_calibration_sequence_hdu(arm_id=arm_id)
68
+
69
+ for fit_options in self.parameters.fit_options_list:
70
+ fit_info_hdu = self.generate_fit_info_hdu(fit_options)
71
+ for inst_options in self.parameters.instrument_processing_options:
72
+ hdu_list = fits.HDUList([fits.PrimaryHDU()])
73
+
74
+ hdu_list.append(
75
+ self.generate_inventory_hdu(
76
+ arm_id=arm_id, fit_options=fit_options, instrument_options=inst_options
77
+ )
78
+ )
79
+
80
+ hdu_list.append(calibration_sequence_hdu)
81
+ hdu_list.append(fit_info_hdu)
82
+
83
+ common_args = {
84
+ "arm_id": arm_id,
85
+ "fit_options": fit_options,
86
+ "instrument_options": inst_options,
87
+ }
88
+ hdu_list.append(self.generate_fit_parameters_hdu(**common_args))
89
+ hdu_list.append(self.generate_modulation_matrices_hdu(**common_args))
90
+ hdu_list.append(self.generate_best_fit_flux_hdu(**common_args))
91
+ hdu_list.append(self.generate_fit_residuals_hdu(**common_args))
92
+ if len(self.beam_list(arm_id)) == 2:
93
+ hdu_list.append(self.generate_single_beam_DMPD_hdu(beam=1, **common_args))
94
+ hdu_list.append(self.generate_single_beam_DMPD_hdu(beam=2, **common_args))
95
+ hdu_list.append(self.generate_DMPD_hdu(**common_args))
96
+
97
+ self.write_trend_output(hdu_list, arm_id, fit_options, inst_options)
98
+
99
+ @staticmethod
100
+ def beam_list(arm_id: str | int) -> list[int]:
101
+ """Return the list of beams the need to be iterated over for a given arm id."""
102
+ if arm_id == "CI":
103
+ return [1]
104
+ return [1, 2]
105
+
106
+ @staticmethod
107
+ def convert_float64_to_float32(input_array: np.ndarray) -> np.ndarray:
108
+ """
109
+ Convert an input `np.float64` array to `np.float32`.
110
+
111
+ Any other array type is returned unchanged.
112
+ """
113
+ if np.issubdtype(input_array.dtype, np.float64):
114
+ return input_array.astype(np.float32, casting="same_kind")
115
+ return input_array
116
+
117
+ def build_hdu(
118
+ self, header: fits.Header, data: np.ndarray | None = None
119
+ ) -> fits.ImageHDU | fits.CompImageHDU:
120
+ """
121
+ Construct an HDU from input header and data objects.
122
+
123
+ This method also formats the header to be nice and readable.
124
+
125
+ If ``data`` is not `None` then the returned HDU will be a `~astropy.io.fits.CompImageHDU`. Header-only HDUs are
126
+ returned as `~astropy.io.fits.ImageHDU` objects.
127
+ """
128
+ if data is None:
129
+ hdu_cls = fits.ImageHDU
130
+ extra_args = dict()
131
+ else:
132
+ data = self.convert_float64_to_float32(data)
133
+ hdu_cls = fits.CompImageHDU
134
+ extra_args = {"tile_shape": self.compute_tile_size_for_array(data)}
135
+
136
+ hdu = hdu_cls(header=header, data=data, **extra_args)
137
+ formatted_header = reformat_trend_header(hdu.header)
138
+ hdu = hdu_cls(header=formatted_header, data=data, **extra_args)
139
+
140
+ return hdu
141
+
142
+ def trend_filename(
143
+ self, arm_id: int | str, fit_options: FitOptions, instrument_options: InstrumentOptionsBase
144
+ ) -> None:
145
+ """
146
+ Construct a single Trend output file name.
147
+
148
+ The format is:
149
+
150
+ trend_polcal_DATE_INSTRUMENT_ARM_FITOPTION-INSTRUMENTOPTION_DATASETID.fits
151
+
152
+ Example
153
+ -------
154
+ "trend_polcal_2020-03-14T12_34_56_DLNIRSP_JBand_tpol_free-no_smooth_ADJFSI.fits"
155
+ """
156
+ instrument = self.constants.instrument
157
+ date_beg = (
158
+ self.constants.earliest_ip_start_time.replace("-", "_")
159
+ .replace(":", "_")
160
+ .replace(".", "_")
161
+ )
162
+ options_str = self.compute_full_fit_opt_id(
163
+ fit_options=fit_options, instrument_options=instrument_options
164
+ )
165
+ return f"trend_polcal_{date_beg}_{instrument}_{arm_id}_{options_str}_{self.constants.dataset_id}.fits"
166
+
167
+ @staticmethod
168
+ def compute_full_fit_opt_id(
169
+ fit_options: FitOptions, instrument_options: InstrumentOptionsBase
170
+ ) -> str:
171
+ """Concatenate the 'names' of both the pac-fit and instrument-processing options."""
172
+ return f"{fit_options.name}-{instrument_options.name}"
173
+
174
+ def update_framevol(self, relative_path: str) -> None:
175
+ """
176
+ Update the second HDU's (Inventory) FRAMEVOL key to be exactly the size of the file on-disk.
177
+
178
+ This is different than the L1 `update_framevol` method because we only update a single HDU (the Inventory HDU).
179
+ """
180
+ full_path = self.scratch.workflow_base_path / relative_path
181
+ compressed_size = full_path.stat().st_size / 1024 / 1024
182
+ hdul = fits.open(full_path, mode="update", disable_image_compression=True)
183
+ hdul[1].header["FRAMEVOL"] = compressed_size
184
+ hdul.flush()
185
+ del hdul
186
+
187
+ def write_trend_output(
188
+ self,
189
+ hdu_list: fits.HDUList,
190
+ arm_id: int | str,
191
+ fit_options: FitOptions,
192
+ instrument_options: InstrumentOptionsBase,
193
+ ) -> None:
194
+ """Write the given HDUList to scratch, tag with FRAME and OUTPUT, and update the FRAMEVOL header key."""
195
+ relative_path = self.trend_filename(
196
+ arm_id=arm_id, fit_options=fit_options, instrument_options=instrument_options
197
+ )
198
+ tags = [
199
+ TrendTag.output(),
200
+ TrendTag.frame(),
201
+ # These next tags are only for easing test writing and debugging
202
+ TrendTag.arm_id(arm_id),
203
+ TrendTag.pac_fit_options(fit_options.name),
204
+ TrendTag.instrument_processing_options(instrument_options.name),
205
+ ]
206
+
207
+ self.write(
208
+ data=hdu_list,
209
+ tags=tags,
210
+ encoder=fits_hdulist_encoder,
211
+ relative_path=relative_path,
212
+ )
213
+
214
+ self.update_framevol(relative_path)
215
+
216
+ # TODO: Should there be a different configuration variable for `validate_trend_on_write`?
217
+ if self.validate_l1_on_write:
218
+ # Replace this logic with the fits_header_decoder once FITS library keys have been removed from the SPEC
219
+ # TODO: Actually do something here once validator is ready
220
+ pass
221
+ # with fits.open(self.scratch.absolute_path(relative_path)) as hdul:
222
+ # header = hdul[1].header
223
+ # spec214_validator.validate(header, extra=False)
224
+
225
+ def generate_fit_info_hdu(self, fit_options: FitOptions) -> fits.ImageHDU:
226
+ """
227
+ Generate the data-less "Fit info" HDU.
228
+
229
+ Header values are pulled from the fit mode definitions that live in `dkist-processing-pac`.
230
+ """
231
+ switches, vary = PolcalDresserParameters.load_fit_mode(fit_mode=fit_options.fit_mode_name)
232
+
233
+ # If we use db M12 values then Q_in *never* varies. See `*-pac` for more info
234
+ q_in_free = vary["Q_in"] and not switches["use_M12"]
235
+
236
+ header = fits.Header()
237
+ header["EXTNAME"] = "Fit info"
238
+ header["QINFREE"] = q_in_free
239
+ header["TPOLFREE"] = vary["t_pol"]
240
+ header["TRETFREE"] = vary["t_ret"]
241
+ header["RMITREND"] = fit_options.remove_I_trend
242
+ header["ISYSPERS"] = switches["I_sys_per_CS_step"]
243
+ header["GLBLTRNS"] = switches["global_transmission"]
244
+ header["GLBLRET"] = switches["global_retardance"]
245
+
246
+ # These perturbation parameters are not currently used
247
+ header["X34PERT"] = 0
248
+ header["T34PERT"] = 0
249
+ header["X56PERT"] = 0
250
+ header["T56PERT"] = 0
251
+ header["UINPERT"] = 0
252
+ header["VINPERT"] = 0
253
+
254
+ return self.build_hdu(header=header)
255
+
256
+ def generate_inventory_hdu(
257
+ self, arm_id: str | int, fit_options: FitOptions, instrument_options: InstrumentOptionsBase
258
+ ) -> fits.ImageHDU:
259
+ """
260
+ Generate the data-less "Inventory" HDU.
261
+
262
+ This is also where instrument-specific headers are added.
263
+ """
264
+ header = fits.Header()
265
+ header["EXTNAME"] = "Inventory"
266
+ header["DSETID"] = self.constants.dataset_id
267
+ header["PROCTYPE"] = "POLCAL"
268
+ # This is just a placeholder value, but it's needed so FRAMEVOL gets properly commented and placed during header formatting
269
+ header["FRAMEVOL"] = -1.0
270
+ header["RRUNID"] = self.recipe_run_id
271
+ header["RECIPEID"] = self.metadata_store_recipe_run.recipeInstance.recipeId
272
+ header["RINSTID"] = self.metadata_store_recipe_run.recipeInstanceId
273
+ header["FILENAME"] = self.trend_filename(
274
+ arm_id=arm_id, fit_options=fit_options, instrument_options=instrument_options
275
+ )
276
+ header["HEADVERS"] = spec_version
277
+ header["HEAD_URL"] = f"{self.docs_base_url}/projects/data-products/en/v{spec_version}"
278
+ header["INFO_URL"] = self.docs_base_url
279
+ calvers = self.version_from_module_name()
280
+ header["CALVERS"] = calvers
281
+ header["CAL_URL"] = (
282
+ f"{self.docs_base_url}/projects/trend/en/v{calvers}/{self.workflow_name}.html"
283
+ )
284
+ header["WKFLNAME"] = self.workflow_name
285
+ header["WKFLVERS"] = self.workflow_version
286
+ header["MANPROCD"] = self.workflow_had_manual_intervention
287
+ header["FILE_ID"] = uuid.uuid4().hex
288
+ header["OBSPR_ID"] = self.constants.observing_program_id_list[0]
289
+ header["EXTOBSID"] = ",".join(self.constants.observing_program_id_list[1:])
290
+ header["EXPER_ID"] = self.constants.experiment_id_list[0]
291
+ header["EXTEXPID"] = ",".join(self.constants.experiment_id_list[1:])
292
+ header["PROP_ID"] = self.constants.proposal_id_list[0]
293
+ header["EXTPRPID"] = ",".join(self.constants.proposal_id_list[1:])
294
+ header["HLSVERS"] = self.constants.hls_version_list[0]
295
+ header["EXTHLSV"] = ",".join(self.constants.hls_version_list[1:])
296
+ header["OPTDATE"] = self.constants.earliest_ip_start_time
297
+ header["LINEWAV"] = self.constants.wavelength_for_arm[arm_id]
298
+ header["INSTRUME"] = self.constants.instrument
299
+ header["FITOPTID"] = self.compute_full_fit_opt_id(
300
+ fit_options=fit_options, instrument_options=instrument_options
301
+ )
302
+ header["ORIGIN"] = "National Solar Observatory"
303
+ header["TELESCOP"] = "Daniel K. Inouye Solar Telescope"
304
+ header["OBSRVTRY"] = "Haleakala High Altitude Observatory Site"
305
+ header["NETWORK"] = "NSF-DKIST"
306
+ header["DATE"] = Time.now().fits
307
+
308
+ match self.constants.instrument:
309
+ case InstrumentName.visp:
310
+ self.add_visp_headers(
311
+ header=header, arm_id=arm_id, instrument_options=instrument_options
312
+ )
313
+ case InstrumentName.cryonirsp:
314
+ self.add_cryonirsp_headers(
315
+ header=header, arm_id=arm_id, instrument_options=instrument_options
316
+ )
317
+ case InstrumentName.dlnirsp:
318
+ self.add_dlnirsp_headers(
319
+ header=header, arm_id=arm_id, instrument_options=instrument_options
320
+ )
321
+
322
+ return self.build_hdu(header=header)
323
+
324
+ def add_visp_headers(
325
+ self, header: fits.Header, arm_id: int, instrument_options: VispInstrumentOptions
326
+ ) -> None:
327
+ """Add keys in the "visp" trend table to a header."""
328
+ header["VSPARMID"] = arm_id
329
+ header["VSPNUMST"] = self.constants.num_modstates
330
+ header["VSPGRTAN"] = self.constants.visp_grating_angle_for_arm[arm_id]
331
+ header["VSPARMPS"] = self.constants.visp_arm_position_for_arm[arm_id]
332
+ header["VSPGRTCN"] = self.constants.visp_grating_constant_for_arm[arm_id]
333
+ header["VSPNSPIX"] = instrument_options.num_spatial_px
334
+ header["VSPNWPIX"] = instrument_options.num_spectral_px
335
+ header["VSPSFORD"] = instrument_options.spatial_smoothing_fit_order
336
+
337
+ def add_cryonirsp_headers(
338
+ self, header: fits.Header, arm_id: str, instrument_options: CryonirspInstrumentOptions
339
+ ) -> None:
340
+ """Add keys in the "cryonirsp" trend table to a header."""
341
+ header["CNARMID"] = arm_id
342
+ header["CNMODNST"] = self.constants.num_modstates
343
+ header["CNFILTNP"] = self.constants.cryonirsp_od_filter_name_for_arm[arm_id]
344
+ header["CNGRTPOS"] = self.constants.cryonirsp_grating_angle_for_arm[arm_id]
345
+ header["CNGRTLAT"] = self.constants.cryonirsp_grating_littrow_angle_for_arm[arm_id]
346
+ header["CNGRTCON"] = self.constants.cryonirsp_grating_constant_for_arm[arm_id]
347
+ header["CNSLITW"] = self.constants.cryonirsp_slit_width_for_arm[arm_id]
348
+ header["CNNSPIX"] = instrument_options.num_spatial_px
349
+ header["CNNWPIX"] = instrument_options.num_spectral_px
350
+
351
+ def add_dlnirsp_headers(
352
+ self, header: fits.Header, arm_id: str, instrument_options: DlnirspInstrumentOptions
353
+ ) -> None:
354
+ """Add keys in the "dlnirsp" trend table to a header."""
355
+ header["DLARMID"] = arm_id
356
+ header["DLNUMST"] = self.constants.num_modstates
357
+ header["DLGRTAN"] = self.constants.dlnirsp_grating_angle_for_arm[arm_id]
358
+ header["DLARMPS"] = self.constants.dlnirsp_arm_position_for_arm[arm_id]
359
+ header["DLGRTCN"] = self.constants.dlnirsp_grating_constant_for_arm[arm_id]
360
+ header["DLNSPIX"] = instrument_options.num_spatial_px
361
+ header["DLNWPIX"] = instrument_options.num_spectral_px
362
+ header["DLSFORD"] = instrument_options.spatial_smoothing_fit_order
363
+
364
+ def generate_calibration_sequence_hdu(self, arm_id: str | int) -> fits.ImageHDU:
365
+ """Generate the "Calibration sequence" HDU that contains information about the full set of input GOS configurations."""
366
+ header = fits.Header()
367
+ header["EXTNAME"] = "Calibration sequence"
368
+ header["CSAX1"] = "CS_STEP"
369
+ header["CSAX2"] = "OPTIC"
370
+ header["CSAX3"] = "Calibration Sequence"
371
+ header["ONAME1"] = "POL_IN"
372
+ header["ONAME2"] = "POL_ANG_DEG"
373
+ header["ONAME3"] = "RET_IN"
374
+ header["ONAME4"] = "RET_ANG_DEG"
375
+ header["ONAME5"] = "DARK_IN"
376
+ header["ONAME6"] = "TAZIMUTH_DEG"
377
+ header["ONAME7"] = "ELEV_ANG_DEG"
378
+ header["ONAME8"] = "TTBLANGL_DEG"
379
+
380
+ data = next(
381
+ self.read(
382
+ tags=[
383
+ TrendTag.intermediate(),
384
+ TrendTag.arm_id(arm_id),
385
+ TrendTag.task_calibration_sequence(),
386
+ ],
387
+ decoder=fits_array_decoder,
388
+ auto_squeeze=False,
389
+ )
390
+ )
391
+
392
+ return self.build_hdu(header=header, data=data)
393
+
394
+ def generate_fit_parameters_hdu(
395
+ self, arm_id: str | int, fit_options: FitOptions, instrument_options: InstrumentOptionsBase
396
+ ) -> fits.CompImageHDU:
397
+ """
398
+ Generate the "Fit parameters" HDU that contains the full set of best-fit parameters.
399
+
400
+ The actual array of fit parameters is constructed in `~dkist_processing_trend.tasks.run_pac_fitter.RunPacFitter.write_best_fit_parameters`.
401
+
402
+ This HDU is compressed because the FOV size can be large.
403
+ """
404
+ header = fits.Header()
405
+ header["EXTNAME"] = "Fit parameters"
406
+ header["NCSSTEPS"] = self.constants.num_cs_steps
407
+ header["FPAX1"] = "[value, init_value, free_in_fit]"
408
+ header["FPAX2"] = "Parameter"
409
+ header["FPAX3"] = "Calibration Sequence"
410
+ header["FPAX4"] = "Detector FITS axis 1"
411
+ header["FPAX5"] = "Detector FITS axis 2"
412
+ header["FPAX6"] = "Beam"
413
+ header["PNAME001"] = "X12"
414
+ header["PNAME002"] = "Tau 12"
415
+ header["PNAME003"] = "X34"
416
+ header["PNAME004"] = "Tau 34"
417
+ header["PNAME005"] = "X56"
418
+ header["PNAME006"] = "Tau 56"
419
+ header["PNAME007"] = "Q_in"
420
+ header["PNAME008"] = "U_in"
421
+ header["PNAME009"] = "V_in"
422
+ header["PNAME010"] = "t_pol"
423
+ header["PNAME011"] = "t_ret"
424
+ header["PNAME012"] = "ret_r"
425
+ header["PNAME013"] = "ret_45"
426
+ header["PNAME014"] = "ret_h"
427
+ header["PNAME015"] = "py"
428
+ for s in range(self.constants.num_cs_steps):
429
+ # TODO: Account for multiple polcal OPs by having "I_sys_CSXX_stepNN"
430
+ header[f"PNAME{s + 16:03d}"] = f"I_sys_step{s:03d}"
431
+
432
+ all_beam_data_list = []
433
+ for beam in self.beam_list(arm_id):
434
+ all_beam_data_list.append(
435
+ next(
436
+ self.read(
437
+ tags=[
438
+ TrendTag.intermediate(),
439
+ TrendTag.arm_id(arm_id),
440
+ TrendTag.pac_fit_options(fit_options.name),
441
+ TrendTag.instrument_processing_options(instrument_options.name),
442
+ TrendTag.beam(beam),
443
+ TrendTag.task_best_fit_parameters(),
444
+ ],
445
+ decoder=fits_array_decoder,
446
+ auto_squeeze=False,
447
+ )
448
+ )
449
+ )
450
+
451
+ # Will add the correct dimension even for a single beam
452
+ all_beam_data = np.stack(all_beam_data_list, axis=0)
453
+
454
+ return self.build_hdu(header=header, data=all_beam_data)
455
+
456
+ def generate_modulation_matrices_hdu(
457
+ self, arm_id: str | int, fit_options: FitOptions, instrument_options: InstrumentOptionsBase
458
+ ) -> fits.CompImageHDU:
459
+ """
460
+ Generate the "Modulation matrices" HDU that contains all modulation matrices for the given instrument FOV.
461
+
462
+ This is where we convert the DEmodulation matrices produced by `dkist_processing_pac` and
463
+ `~dkist_processing_trend.tasks.run_pac_fitter.RunPacFitter.write_best_fit_demodulation_matrices` into modulation matrices.
464
+
465
+ This HDU is compressed because the FOV size can be large.
466
+ """
467
+ header = fits.Header()
468
+ header["EXTNAME"] = "Modulation matrices"
469
+ header["MMAX1"] = "Stokes parameter"
470
+ header["MMAX2"] = "Modulator state"
471
+ header["MMAX3"] = "Detector FITS axis 1"
472
+ header["MMAX4"] = "Detector FITS axis 2"
473
+ header["MMAX5"] = "Beam"
474
+
475
+ all_beam_data_list = []
476
+ for beam in self.beam_list(arm_id):
477
+ beam_demod = next(
478
+ self.read(
479
+ tags=[
480
+ TrendTag.intermediate(),
481
+ TrendTag.arm_id(arm_id),
482
+ TrendTag.pac_fit_options(fit_options.name),
483
+ TrendTag.instrument_processing_options(instrument_options.name),
484
+ TrendTag.beam(beam),
485
+ TrendTag.task_best_fit_demodulation_matrices(),
486
+ ],
487
+ decoder=fits_array_decoder,
488
+ auto_squeeze=False,
489
+ )
490
+ )
491
+ fov_shape = beam_demod.shape[:-2]
492
+ num_points = np.prod(fov_shape)
493
+ beam_mod_mat = np.empty(fov_shape + (self.constants.num_modstates, 4))
494
+
495
+ for point in range(num_points):
496
+ idx = np.unravel_index(point, fov_shape)
497
+ point_demod = beam_demod[idx]
498
+ if np.sum(np.isnan(point_demod)) > 0:
499
+ point_mod = np.full((self.constants.num_modstates, 4), np.nan)
500
+ else:
501
+ point_mod = np.linalg.pinv(point_demod)
502
+
503
+ beam_mod_mat[idx] = point_mod
504
+
505
+ all_beam_data_list.append(beam_mod_mat)
506
+
507
+ # Will add the correct dimension even for a single beam
508
+ all_beam_data = np.stack(all_beam_data_list, axis=0)
509
+
510
+ return self.build_hdu(header=header, data=all_beam_data)
511
+
512
+ def generate_best_fit_flux_hdu(
513
+ self, arm_id: str | int, fit_options: FitOptions, instrument_options: InstrumentOptionsBase
514
+ ) -> fits.CompImageHDU:
515
+ """
516
+ Generate the "Best fit flux" HDU that contains the full set of best-fit model fluxes.
517
+
518
+ The actual flux array is constructed in `~dkist_processing_trend.tasks.run_pac_fitter.RunPacFitter.write_best_fit_flux_and_residuals`.
519
+
520
+ This HDU is compressed because the FOV size can be large.
521
+ """
522
+ header = fits.Header()
523
+ header["EXTNAME"] = "Best fit flux"
524
+ header["BFFAX1"] = "CS step"
525
+ header["BFFAX2"] = "Modulator state"
526
+ header["BFFAX3"] = "Detector FITS axis 1"
527
+ header["BFFAX4"] = "Detector FITS axis 2"
528
+ header["BFFAX5"] = "Beam"
529
+
530
+ all_beam_data_list = []
531
+ for beam in self.beam_list(arm_id):
532
+ all_beam_data_list.append(
533
+ next(
534
+ self.read(
535
+ tags=[
536
+ TrendTag.intermediate(),
537
+ TrendTag.arm_id(arm_id),
538
+ TrendTag.pac_fit_options(fit_options.name),
539
+ TrendTag.instrument_processing_options(instrument_options.name),
540
+ TrendTag.beam(beam),
541
+ TrendTag.task_best_fit_flux(),
542
+ ],
543
+ decoder=fits_array_decoder,
544
+ auto_squeeze=False,
545
+ )
546
+ )
547
+ )
548
+
549
+ # Will add the correct dimension even for a single beam
550
+ all_beam_data = np.stack(all_beam_data_list, axis=0)
551
+
552
+ return self.build_hdu(header=header, data=all_beam_data)
553
+
554
+ def generate_fit_residuals_hdu(
555
+ self, arm_id: str | int, fit_options: FitOptions, instrument_options: InstrumentOptionsBase
556
+ ) -> fits.CompImageHDU:
557
+ """
558
+ Generate the "Best fit residuals" HDU that contains the fitting residuals of the best-fit solution.
559
+
560
+ The actual flux array is constructed in `~dkist_processing_trend.tasks.run_pac_fitter.RunPacFitter.write_best_fit_flux_and_residuals`.
561
+
562
+ This HDU is compressed because the FOV size can be large.
563
+ """
564
+ header = fits.Header()
565
+ header["EXTNAME"] = "Best fit residuals"
566
+ header["BFRAX1"] = "CS step"
567
+ header["BFRAX2"] = "Modulator state"
568
+ header["BFRAX3"] = "Detector FITS axis 1"
569
+ header["BFRAX4"] = "Detector FITS axis 2"
570
+ header["BFRAX5"] = "Beam"
571
+
572
+ all_beam_data_list = []
573
+ for beam in self.beam_list(arm_id):
574
+ all_beam_data_list.append(
575
+ next(
576
+ self.read(
577
+ tags=[
578
+ TrendTag.intermediate(),
579
+ TrendTag.arm_id(arm_id),
580
+ TrendTag.pac_fit_options(fit_options.name),
581
+ TrendTag.instrument_processing_options(instrument_options.name),
582
+ TrendTag.beam(beam),
583
+ TrendTag.task_fit_residuals(),
584
+ ],
585
+ decoder=fits_array_decoder,
586
+ auto_squeeze=False,
587
+ )
588
+ )
589
+ )
590
+
591
+ # Will add the correct dimension even for a single beam
592
+ all_beam_data = np.stack(all_beam_data_list, axis=0)
593
+
594
+ return self.build_hdu(header=header, data=all_beam_data)
595
+
596
+ def generate_single_beam_DMPD_hdu(
597
+ self,
598
+ beam: int,
599
+ arm_id: str | int,
600
+ fit_options: FitOptions,
601
+ instrument_options: InstrumentOptionsBase,
602
+ ) -> fits.CompImageHDU:
603
+ """
604
+ Generate the "Demodulated polcal data" HDU that contains the "processed" and demodulated input data for a single beam.
605
+
606
+ This HDU is compressed because this HDU is huge.
607
+ """
608
+ header = fits.Header()
609
+ header["EXTNAME"] = f"Beam {beam} demodulated polcal data"
610
+ header["DMPDAX1"] = "Input data axis 1"
611
+ header["DMPDAX2"] = "Input data axis 2"
612
+ header["DMPDAX3"] = "Stokes"
613
+ header["DMPDAX4"] = "CS Step"
614
+ header["DMPDAX5"] = "Calibration Sequence"
615
+
616
+ data = next(
617
+ self.read(
618
+ tags=[
619
+ TrendTag.intermediate(),
620
+ TrendTag.arm_id(arm_id),
621
+ TrendTag.beam(beam),
622
+ TrendTag.pac_fit_options(fit_options.name),
623
+ TrendTag.instrument_processing_options(instrument_options.name),
624
+ TrendTag.task_single_beam_demodulated_polcal_data(),
625
+ ],
626
+ decoder=fits_array_decoder,
627
+ auto_squeeze=False,
628
+ )
629
+ )
630
+
631
+ return self.build_hdu(header=header, data=data)
632
+
633
+ def generate_DMPD_hdu(
634
+ self, arm_id: str | int, fit_options: FitOptions, instrument_options: InstrumentOptionsBase
635
+ ) -> fits.CompImageHDU:
636
+ """
637
+ Generate the "Demodulated polcal data" HDU that contains the "processed" and demodulated input data.
638
+
639
+ This HDU is compressed because this HDU is huge.
640
+ """
641
+ header = fits.Header()
642
+ header["EXTNAME"] = "Demodulated polcal data"
643
+ header["DMPDAX1"] = "Input data axis 1"
644
+ header["DMPDAX2"] = "Input data axis 2"
645
+ header["DMPDAX3"] = "Stokes"
646
+ header["DMPDAX4"] = "CS Step"
647
+ header["DMPDAX5"] = "Calibration Sequence"
648
+
649
+ data = next(
650
+ self.read(
651
+ tags=[
652
+ TrendTag.intermediate(),
653
+ TrendTag.arm_id(arm_id),
654
+ TrendTag.pac_fit_options(fit_options.name),
655
+ TrendTag.instrument_processing_options(instrument_options.name),
656
+ TrendTag.task_demodulated_polcal_data(),
657
+ ],
658
+ decoder=fits_array_decoder,
659
+ auto_squeeze=False,
660
+ )
661
+ )
662
+
663
+ return self.build_hdu(header=header, data=data)
@@ -0,0 +1 @@
1
+ """Tests for testing."""