dkist-processing-trend 0.1.0rc1__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. changelog/1.feature.rst +1 -0
  3. dkist_processing_trend/__init__.py +10 -0
  4. dkist_processing_trend/config.py +11 -0
  5. dkist_processing_trend/models/__init__.py +1 -0
  6. dkist_processing_trend/models/constants.py +143 -0
  7. dkist_processing_trend/models/fit_options.py +15 -0
  8. dkist_processing_trend/models/fits_access.py +65 -0
  9. dkist_processing_trend/models/instrument.py +11 -0
  10. dkist_processing_trend/models/instrument_options.py +35 -0
  11. dkist_processing_trend/models/parameters.py +134 -0
  12. dkist_processing_trend/models/tags.py +117 -0
  13. dkist_processing_trend/models/task_name.py +20 -0
  14. dkist_processing_trend/parsers/__init__.py +1 -0
  15. dkist_processing_trend/parsers/arm_id.py +103 -0
  16. dkist_processing_trend/parsers/instrument_unique_bud.py +40 -0
  17. dkist_processing_trend/parsers/time.py +27 -0
  18. dkist_processing_trend/parsers/trend_l0_fits_access.py +123 -0
  19. dkist_processing_trend/tasks/__init__.py +11 -0
  20. dkist_processing_trend/tasks/dark.py +56 -0
  21. dkist_processing_trend/tasks/gain.py +74 -0
  22. dkist_processing_trend/tasks/parse.py +169 -0
  23. dkist_processing_trend/tasks/prepare_fit_data_base.py +257 -0
  24. dkist_processing_trend/tasks/run_pac_fitter.py +392 -0
  25. dkist_processing_trend/tasks/trend_base.py +97 -0
  26. dkist_processing_trend/tasks/trend_output_data.py +167 -0
  27. dkist_processing_trend/tasks/visp/__init__.py +6 -0
  28. dkist_processing_trend/tasks/visp/visp_dmpd.py +405 -0
  29. dkist_processing_trend/tasks/visp/visp_extract_beam.py +14 -0
  30. dkist_processing_trend/tasks/visp/visp_geometric.py +260 -0
  31. dkist_processing_trend/tasks/visp/visp_prep_fit_data.py +162 -0
  32. dkist_processing_trend/tasks/visp/visp_process_demod.py +236 -0
  33. dkist_processing_trend/tasks/write_trend.py +663 -0
  34. dkist_processing_trend/tests/__init__.py +1 -0
  35. dkist_processing_trend/tests/conftest.py +718 -0
  36. dkist_processing_trend/tests/local_trial_workflows/__init__.py +0 -0
  37. dkist_processing_trend/tests/local_trial_workflows/l0_to_trend_visp_polcal.py +294 -0
  38. dkist_processing_trend/tests/local_trial_workflows/local_trial_helpers.py +488 -0
  39. dkist_processing_trend/tests/test_arm_class_factory.py +39 -0
  40. dkist_processing_trend/tests/test_base_tasks.py +86 -0
  41. dkist_processing_trend/tests/test_constants.py +120 -0
  42. dkist_processing_trend/tests/test_dark.py +97 -0
  43. dkist_processing_trend/tests/test_gain.py +135 -0
  44. dkist_processing_trend/tests/test_parameters.py +149 -0
  45. dkist_processing_trend/tests/test_parse.py +276 -0
  46. dkist_processing_trend/tests/test_prep_fit_data_base.py +233 -0
  47. dkist_processing_trend/tests/test_publish_catalog_messages.py +45 -0
  48. dkist_processing_trend/tests/test_run_pac_fitter.py +371 -0
  49. dkist_processing_trend/tests/test_stems.py +75 -0
  50. dkist_processing_trend/tests/test_transfer_output_data.py +76 -0
  51. dkist_processing_trend/tests/test_trend_fits_access.py +173 -0
  52. dkist_processing_trend/tests/test_visp.py +870 -0
  53. dkist_processing_trend/tests/test_workflows.py +10 -0
  54. dkist_processing_trend/tests/test_write_trend.py +460 -0
  55. dkist_processing_trend/workflows/__init__.py +3 -0
  56. dkist_processing_trend/workflows/arm_class_factory.py +30 -0
  57. dkist_processing_trend/workflows/visp.py +59 -0
  58. dkist_processing_trend-0.1.0rc1.dist-info/METADATA +549 -0
  59. dkist_processing_trend-0.1.0rc1.dist-info/RECORD +66 -0
  60. dkist_processing_trend-0.1.0rc1.dist-info/WHEEL +5 -0
  61. dkist_processing_trend-0.1.0rc1.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,257 @@
1
+ """Base task for preparing input data for the PAC fitter."""
2
+
3
+ from abc import ABC
4
+ from abc import abstractmethod
5
+ from collections import defaultdict
6
+
7
+ import numpy as np
8
+ from dkist_processing_common.codecs.asdf import asdf_encoder
9
+ from dkist_processing_common.codecs.fits import fits_array_decoder
10
+ from dkist_processing_common.codecs.fits import fits_header_decoder
11
+ from dkist_processing_math.arithmetic import divide_arrays_by_array
12
+ from dkist_processing_math.arithmetic import subtract_array_from_arrays
13
+ from dkist_processing_math.statistics import average_numpy_arrays
14
+ from dkist_service_configuration.logging import logger
15
+
16
+ from dkist_processing_trend.models.instrument_options import InstrumentOptionsBase
17
+ from dkist_processing_trend.models.tags import TrendTag
18
+ from dkist_processing_trend.parsers.trend_l0_fits_access import TrendL0FitsAccess
19
+ from dkist_processing_trend.tasks.trend_base import TrendArmTaskBase
20
+
21
+
22
+ class PrepareFitDataBase(TrendArmTaskBase, ABC):
23
+ """
24
+ Base class for processing INPUT data to prepare it for fitting by `~dkist_processing_trend.tasks.run_pac_fitter.RunPacFitter`.
25
+
26
+ This class provides a populated `run` method that does steps common to all instruments (e.g., dark and gain correction).
27
+
28
+ Instrument-specific subclasses need to define the following methods:
29
+
30
+ * `extract_beam` - For getting a certain beam from a full-FOV array
31
+ * `apply_global_instrument_options` - Use any `*InstrumentOptions` to produce the "global" PAC input data for a single cs step, beam, and modstate
32
+ * `apply_local_instrument_options` - Use any `*InstrumentOptions` to produce the "local" PAC input data for a single cs step, beam, and modstate
33
+
34
+ Parameters
35
+ ----------
36
+ arm_id
37
+ id of the instrument arm to operate on
38
+
39
+ recipe_run_id
40
+ id of the recipe run used to identify the workflow run this task is part of
41
+
42
+ workflow_name
43
+ name of the workflow to which this instance of the task belongs
44
+
45
+ workflow_version
46
+ version of the workflow to which this instance of the task belongs
47
+ """
48
+
49
+ record_provenance = True
50
+
51
+ def run(self):
52
+ """
53
+ Prepare input data for fitting with `dkist_processing_pac`.
54
+
55
+ At a high level this involves three steps:
56
+
57
+ #. Perform dark and gain corrections to all input frames
58
+ #. Apply any instrument-specific processing (e.g., binning) to the resulting frames
59
+ #. Organize the results into the structure expected by `~dkist_processing_trend.tasks.run_pac_fitter.RunPacFitter`
60
+
61
+ This method should not be overloaded by instrument-specific subclasses. Rather they should define `extract_beam`,
62
+ `apply_global_instrument_options`, and `apply_local_instrument_options`, which are called from this method.
63
+ """
64
+ logger.info("Loading INTERMEDIATE dark frame")
65
+ avg_dark = next(
66
+ self.read(
67
+ tags=[
68
+ TrendTag.intermediate(),
69
+ TrendTag.frame(),
70
+ TrendTag.arm_id(self.arm_id),
71
+ TrendTag.task_dark(),
72
+ ],
73
+ decoder=fits_array_decoder,
74
+ )
75
+ )
76
+
77
+ logger.info("Loading INTERMEDIATE gain frame")
78
+ avg_gain = next(
79
+ self.read(
80
+ tags=[
81
+ TrendTag.intermediate(),
82
+ TrendTag.frame(),
83
+ TrendTag.arm_id(self.arm_id),
84
+ TrendTag.task_gain(),
85
+ ],
86
+ decoder=fits_array_decoder,
87
+ )
88
+ )
89
+
90
+ beam_dark_dict = {
91
+ beam: self.extract_beam(array=avg_dark, beam=beam)
92
+ for beam in range(1, self.num_beams + 1)
93
+ }
94
+ beam_gain_dict = {
95
+ beam: self.extract_beam(array=avg_gain, beam=beam)
96
+ for beam in range(1, self.num_beams + 1)
97
+ }
98
+
99
+ with self.telemetry_span("Prepare polcal fit inputs"):
100
+ for inst_options in self.parameters.instrument_processing_options:
101
+ for beam in range(1, self.num_beams + 1):
102
+ global_obj_dict = defaultdict(list)
103
+ local_obj_dict = defaultdict(list)
104
+ beam_dark = beam_dark_dict[beam]
105
+ beam_gain = beam_gain_dict[beam]
106
+ for cs_step in range(self.constants.num_cs_steps):
107
+ for modstate in range(1, self.constants.num_modstates + 1):
108
+ logger.info(
109
+ f"Processing instrument options = {inst_options.name}, {beam = }, {cs_step = }, and {modstate = }"
110
+ )
111
+ global_obj, local_obj = self.process_single_modstate(
112
+ instrument_options=inst_options,
113
+ cs_step=cs_step,
114
+ modstate=modstate,
115
+ beam=beam,
116
+ avg_dark=beam_dark,
117
+ avg_gain=beam_gain,
118
+ )
119
+ global_obj_dict[cs_step].append(global_obj)
120
+ local_obj_dict[cs_step].append(local_obj)
121
+
122
+ logger.info(
123
+ f"Writing PAC inputs for instrument options = {inst_options.name} and {beam = }"
124
+ )
125
+ self.write_pac_inputs(
126
+ global_obj_dict=global_obj_dict,
127
+ local_obj_dict=local_obj_dict,
128
+ instrument_options=inst_options,
129
+ beam=beam,
130
+ )
131
+
132
+ @property
133
+ def num_beams(self) -> int:
134
+ """Return the number of beams associated with this instance's arm_id."""
135
+ if self.arm_id == "CI":
136
+ return 1
137
+ return 2
138
+
139
+ def process_single_modstate(
140
+ self,
141
+ instrument_options: InstrumentOptionsBase,
142
+ cs_step: int,
143
+ modstate: int,
144
+ beam: int,
145
+ avg_dark: np.ndarray,
146
+ avg_gain: np.ndarray,
147
+ ) -> tuple[TrendL0FitsAccess, TrendL0FitsAccess]:
148
+ """
149
+ Process data for a single cs step, beam, and modstate.
150
+
151
+ This involves doing dark and gain correction before passing the result to the instrument-defined methods used to
152
+ extract global and local PAC input data.
153
+
154
+ If more than one file is found for the given ``(cs_step, beam, modstate)`` tuple then they are averaged together.
155
+ In this case, the header in the returned `~dkist_processing_trend.parsers.trend_l0_fits_access.TrendL0FitsAccess`
156
+ objects is randomly chosen from the input frames.
157
+ """
158
+ tags = [
159
+ TrendTag.task_polcal(),
160
+ TrendTag.arm_id(self.arm_id),
161
+ TrendTag.cs_step(cs_step),
162
+ TrendTag.modstate(modstate),
163
+ ]
164
+ logger.info(f"Found {self.count(tags)} frame(s)")
165
+
166
+ arrays = self.read(tags=tags, decoder=fits_array_decoder)
167
+ avg_array = average_numpy_arrays(arrays)
168
+
169
+ beam_array = self.extract_beam(array=avg_array, beam=beam)
170
+
171
+ dark_corrected_array = subtract_array_from_arrays(
172
+ arrays=beam_array, array_to_subtract=avg_dark
173
+ )
174
+ gain_corrected_array = next(
175
+ divide_arrays_by_array(arrays=dark_corrected_array, array_to_divide_by=avg_gain)
176
+ )
177
+
178
+ global_array = self.apply_global_instrument_options(
179
+ array=gain_corrected_array, instrument_options=instrument_options
180
+ )
181
+ local_array = self.apply_local_instrument_options(
182
+ array=gain_corrected_array, instrument_options=instrument_options
183
+ )
184
+
185
+ # Any header from the set of input data will do
186
+ header = next(self.read(tags=tags, decoder=fits_header_decoder))
187
+
188
+ return TrendL0FitsAccess(header=header, data=global_array), TrendL0FitsAccess(
189
+ header=header, data=local_array
190
+ )
191
+
192
+ def write_pac_inputs(
193
+ self,
194
+ global_obj_dict: dict[int, list[TrendL0FitsAccess]],
195
+ local_obj_dict: dict[int, list[TrendL0FitsAccess]],
196
+ instrument_options: InstrumentOptionsBase,
197
+ beam: int,
198
+ ) -> None:
199
+ """
200
+ Write the dicts containing the global and local data structures to disk.
201
+
202
+ Each of these data structures is essentially a `~dkist_processing_pac.input_data.drawer.Drawer`, but saved as
203
+ dicts in an ASDF file for easier serialization. This method converts each
204
+ `~dkist_processing_trend.parsers.trend_l0_fits_access.TrendL0FitsAccess` object into a dict of "header" and
205
+ "data" prior to writing.
206
+ """
207
+ global_output_dict = {
208
+ cs_step: [
209
+ {"header": fits_obj.header_dict, "data": fits_obj.data}
210
+ for fits_obj in modstate_list
211
+ ]
212
+ for cs_step, modstate_list in global_obj_dict.items()
213
+ }
214
+ local_output_dict = {
215
+ cs_step: [
216
+ {"header": fits_obj.header_dict, "data": fits_obj.data}
217
+ for fits_obj in modstate_list
218
+ ]
219
+ for cs_step, modstate_list in local_obj_dict.items()
220
+ }
221
+
222
+ base_tags = [
223
+ TrendTag.intermediate(),
224
+ TrendTag.arm_id(self.arm_id),
225
+ TrendTag.instrument_processing_options(instrument_options.name),
226
+ TrendTag.beam(beam),
227
+ ]
228
+ self.write(
229
+ data=global_output_dict,
230
+ tags=base_tags + [TrendTag.task_global_pac_input()],
231
+ encoder=asdf_encoder,
232
+ )
233
+ self.write(
234
+ data=local_output_dict,
235
+ tags=base_tags + [TrendTag.task_local_pac_input()],
236
+ encoder=asdf_encoder,
237
+ )
238
+
239
+ @abstractmethod
240
+ def extract_beam(self, array: np.ndarray, beam: int) -> np.ndarray:
241
+ """Extract the given beam from the input array."""
242
+
243
+ @abstractmethod
244
+ def apply_global_instrument_options(
245
+ self, array: np.ndarray, instrument_options: InstrumentOptionsBase
246
+ ) -> np.ndarray:
247
+ """
248
+ Do whatever instrument-specific processing is needed to produce a global PAC data point.
249
+
250
+ The shape of the final output should be (1, 1).
251
+ """
252
+
253
+ @abstractmethod
254
+ def apply_local_instrument_options(
255
+ self, array: np.ndarray, instrument_options: InstrumentOptionsBase
256
+ ) -> np.ndarray:
257
+ """Do whatever instrument-specific processing is needed to produce a local PAC data point."""
@@ -0,0 +1,392 @@
1
+ """Task for running processed POLCAL data through the `dkist_processing_pac` fitter."""
2
+
3
+ import numpy as np
4
+ from dkist_processing_common.codecs.asdf import asdf_decoder
5
+ from dkist_processing_common.codecs.fits import fits_array_encoder
6
+ from dkist_processing_pac.fitter.fitter_parameters import CU_PARAMS
7
+ from dkist_processing_pac.fitter.fitting_core import compare_I
8
+ from dkist_processing_pac.fitter.polcal_fitter import PolcalFitter
9
+ from dkist_processing_pac.input_data.drawer import Drawer
10
+ from dkist_processing_pac.input_data.dresser import Dresser
11
+ from dkist_service_configuration.logging import logger
12
+
13
+ from dkist_processing_trend.models.tags import TrendTag
14
+ from dkist_processing_trend.parsers.trend_l0_fits_access import TrendL0FitsAccess
15
+ from dkist_processing_trend.tasks.trend_base import TrendArmTaskBase
16
+
17
+ __all__ = ["RunPacFitter"]
18
+
19
+ # The are the names of parameters as used by `dkist-processing-pac`, which are slightly different than their "common" names
20
+ ORDERED_PARAMETER_NAMES = [
21
+ "x12",
22
+ "t12",
23
+ "x34",
24
+ "t34",
25
+ "x56",
26
+ "t56",
27
+ "Q_in",
28
+ "U_in",
29
+ "V_in",
30
+ "t_pol",
31
+ "t_ret",
32
+ "ret0r",
33
+ "ret045",
34
+ "ret0h",
35
+ "py",
36
+ ]
37
+
38
+
39
+ class RunPacFitter(TrendArmTaskBase):
40
+ """
41
+ Run prepared POLCAL data through `dkist_processing_pac` to fit the CU parameters and produce demodulation matrices.
42
+
43
+ Parameters
44
+ ----------
45
+ arm_id
46
+ id of the instrument arm to operate on
47
+
48
+ recipe_run_id
49
+ id of the recipe run used to identify the workflow run this task is part of
50
+
51
+ workflow_name
52
+ name of the workflow to which this instance of the task belongs
53
+
54
+ workflow_version
55
+ version of the workflow to which this instance of the task belongs
56
+ """
57
+
58
+ record_provenance = True
59
+
60
+ def run(self):
61
+ """
62
+ Run all prepared POLCAL datasets through the `~dkist_processing_pac.fitter.polcal_fitter.PolcalFitter` and save the results.
63
+
64
+ Here is where we loop over all PA&C fitting options described in the
65
+ `~dkist_processing_trend.models.parameters.TrendParameters.fit_options_list`. Each run gets its own unique set of
66
+ outputs.
67
+ """
68
+ num_beams = 1 if self.arm_id == "CI" else 2
69
+ cs_config_saved = False
70
+
71
+ for inst_options in self.parameters.instrument_processing_options:
72
+ inst_option_name = inst_options.name
73
+ for beam in range(1, num_beams + 1):
74
+ logger.info(f"Loading PAC input data for {beam = }")
75
+ global_input_dict = next(
76
+ self.read(
77
+ tags=[
78
+ TrendTag.intermediate(),
79
+ TrendTag.arm_id(self.arm_id),
80
+ TrendTag.instrument_processing_options(inst_option_name),
81
+ TrendTag.task_global_pac_input(),
82
+ TrendTag.beam(beam),
83
+ ],
84
+ decoder=asdf_decoder,
85
+ )
86
+ )
87
+ local_input_dict = next(
88
+ self.read(
89
+ tags=[
90
+ TrendTag.intermediate(),
91
+ TrendTag.arm_id(self.arm_id),
92
+ TrendTag.instrument_processing_options(inst_option_name),
93
+ TrendTag.task_local_pac_input(),
94
+ TrendTag.beam(beam),
95
+ ],
96
+ decoder=asdf_decoder,
97
+ )
98
+ )
99
+
100
+ if not cs_config_saved:
101
+ logger.info("Writing CS GOS configuration")
102
+ self.write_calibration_sequence(global_input_dict)
103
+ cs_config_saved = True
104
+
105
+ for fit_options in self.parameters.fit_options_list:
106
+ with self.telemetry_span(
107
+ f"Run PAC fit for inst processing option = {inst_option_name}, {beam = }, and fit options = {fit_options.name}"
108
+ ):
109
+ logger.info(
110
+ f"Running fits for inst processing option = {inst_option_name}, {beam = }, and fit options = {fit_options.name}"
111
+ )
112
+ global_dresser = self.populate_dresser(
113
+ global_input_dict, remove_I_trend=fit_options.remove_I_trend
114
+ )
115
+ local_dresser = self.populate_dresser(
116
+ local_input_dict, remove_I_trend=fit_options.remove_I_trend
117
+ )
118
+
119
+ pac_fitter = PolcalFitter(
120
+ local_dresser=local_dresser,
121
+ global_dresser=global_dresser,
122
+ fit_mode=fit_options.fit_mode_name,
123
+ init_set=fit_options.init_set_name,
124
+ inherit_global_vary_in_local_fit=True,
125
+ suppress_local_starting_values=True,
126
+ fit_TM=False,
127
+ )
128
+
129
+ with self.telemetry_span(
130
+ f"Saving fit results for {beam = } and fit options = {fit_options.name}"
131
+ ):
132
+ common_kwargs = {
133
+ "beam": beam,
134
+ "inst_options_name": inst_option_name,
135
+ "fit_options_name": fit_options.name,
136
+ }
137
+ logger.info("Writing best-fit parameters")
138
+ self.write_best_fit_parameters(pac_fitter, **common_kwargs)
139
+
140
+ logger.info("Writing best-fit demodulation matrices")
141
+ self.write_best_fit_demodulation_matrices(pac_fitter, **common_kwargs)
142
+
143
+ logger.info("Writing best-fit flux and residuals")
144
+ self.write_best_fit_flux_and_residuals(pac_fitter, **common_kwargs)
145
+
146
+ def populate_dresser(
147
+ self,
148
+ pac_input_dict: dict[int, list[dict[str, dict | np.ndarray]]],
149
+ remove_I_trend: bool,
150
+ skip_darks: bool = True,
151
+ ) -> Dresser:
152
+ """
153
+ Convert a raw set of dicts that live on scratch as an ASDF file into a `~dkist_processing_pac.input_data.dresser.Dresser`.
154
+
155
+ `Drawers <dkist_processing_pac.input_data.drawer.Drawer>` expect to be instantiated with a dict containing lists
156
+ of `~dkist_processing_trend.parsers.trend_l0_fits_access.TrendL0FitsAccess` objects but we store these
157
+ "pac input data" as lists of dicts containing "header" and "data" keys. So the first step is to convert these
158
+ raw dictionaries into `~dkist_processing_trend.parsers.trend_l0_fits_access.TrendL0FitsAccess` objects, then we
159
+ can use them to instantiate `Drawers <dkist_processing_pac.input_data.drawer.Drawer>` and finally a
160
+ `~dkist_processing_pac.input_data.dresser.Dresser`.
161
+ """
162
+ dresser = Dresser()
163
+ fits_access_dict = dict()
164
+
165
+ for cs_step in range(self.constants.num_cs_steps):
166
+ cs_step_dict_list = pac_input_dict[cs_step]
167
+ cs_step_fits_access_list = []
168
+
169
+ for cs_step_dict in cs_step_dict_list:
170
+ fits_access_obj = TrendL0FitsAccess(
171
+ header=cs_step_dict["header"], data=cs_step_dict["data"]
172
+ )
173
+ cs_step_fits_access_list.append(fits_access_obj)
174
+
175
+ fits_access_dict[cs_step] = cs_step_fits_access_list
176
+
177
+ dresser.add_drawer(
178
+ Drawer(fits_access_dict, remove_I_trend=remove_I_trend, skip_darks=skip_darks)
179
+ )
180
+ return dresser
181
+
182
+ def write_calibration_sequence(
183
+ self, pac_input_dict: dict[int, list[dict[str, dict | np.ndarray]]]
184
+ ) -> None:
185
+ """
186
+ Write the Calibration Sequence configuration to scratch.
187
+
188
+ The result has shape ``(K, 8, N)``, where ``K`` is the number of polcal OPs/calibration sequences, and ``N`` is
189
+ the number of steps per OP/sequence. The length-8 dimension corresponds to the 8 optical parameters that describe the
190
+ calibration sequence.
191
+
192
+ If the different polcal OPs have a different number of steps then ``N`` is the length of the longest OP and the data
193
+ for shorter OPs is padded with NaNs.
194
+ """
195
+ dresser = self.populate_dresser(pac_input_dict, remove_I_trend=False, skip_darks=False)
196
+
197
+ longest_cs_num_steps = max(dresser.drawer_step_list)
198
+ full_data = np.empty((dresser.numdrawers, 8, longest_cs_num_steps))
199
+
200
+ for cs_num, drawer in enumerate(dresser.drawers):
201
+ drawer_data_stack = np.vstack(
202
+ [
203
+ drawer.pol_in,
204
+ drawer.theta_pol_steps,
205
+ drawer.ret_in,
206
+ drawer.theta_ret_steps,
207
+ drawer.dark_in,
208
+ drawer.azimuth,
209
+ drawer.elevation,
210
+ drawer.table_angle,
211
+ ]
212
+ )
213
+
214
+ if step_deficit := drawer.numsteps - longest_cs_num_steps > 0:
215
+ drawer_data_stack = np.pad(
216
+ drawer_data_stack, ((0, 0), (0, step_deficit)), constant_values=np.nan
217
+ )
218
+
219
+ full_data[cs_num, :, :] = drawer_data_stack
220
+
221
+ self.write(
222
+ data=full_data,
223
+ tags=[
224
+ TrendTag.intermediate(),
225
+ TrendTag.arm_id(self.arm_id),
226
+ TrendTag.task_calibration_sequence(),
227
+ ],
228
+ encoder=fits_array_encoder,
229
+ )
230
+
231
+ def write_best_fit_parameters(
232
+ self, fitter: PolcalFitter, beam: int, inst_options_name: str, fit_options_name: str
233
+ ):
234
+ """
235
+ Collect ALL best-fit parameter values (including those fixed in the fit) and write to a single array.
236
+
237
+ The full output shape is ``(*FOV_shape, K, 15 + N, 3)``, where FOV_shape is the shape of the input POLCAL data,
238
+ K is the number of polcal OPs/calibration sequences, and N is the number of steps in each sequence. The last
239
+ dimension contains the best-fit value in its 0th index, the initial value at index 1, and whether that parameter
240
+ varied in the fit at index 2. Note that many parameters are held to be the same across polcal OPs; these
241
+ parameters will have the same values for all slices of the "K" index.
242
+
243
+ If the different polcal OPs have a different number of steps then N is the length of the longest OP and the data
244
+ for shorter OPs is padded with NaNs.
245
+ """
246
+ fov_shape = fitter.local_objects.dresser.shape
247
+ num_cs = fitter.local_objects.dresser.numdrawers
248
+ steps_per_cs = fitter.local_objects.dresser.drawer_step_list
249
+ longest_cs_num_steps = max(steps_per_cs)
250
+ init_parameters = fitter.local_objects.init_parameters
251
+ fit_parameters = fitter.fit_parameters
252
+
253
+ full_output = np.empty(fov_shape + (num_cs, 15 + longest_cs_num_steps, 3))
254
+
255
+ num_points = np.prod(fov_shape)
256
+ for point in range(num_points):
257
+ idx = np.unravel_index(point, fov_shape)
258
+ point_parameters = fit_parameters[idx]
259
+ init_point_parameters = init_parameters[idx]
260
+
261
+ for cs in range(num_cs):
262
+ cs_data = np.empty((15 + steps_per_cs[cs], 3))
263
+
264
+ # These are all parameters except for the I_sys params, which we'll add in a moment
265
+ for p, param_name in enumerate(ORDERED_PARAMETER_NAMES):
266
+
267
+ if param_name in CU_PARAMS:
268
+ param_name = f"{param_name}_CS{cs:02n}"
269
+
270
+ if param_name == "py":
271
+ value = fitter.global_objects.calibration_unit.py
272
+ vary = False
273
+ init_value = value
274
+ else:
275
+ parameter = point_parameters[param_name]
276
+ value = parameter.value
277
+ vary = parameter.vary
278
+ init_value = init_point_parameters[param_name].value
279
+
280
+ cs_data[p] = np.array([value, init_value, vary])
281
+
282
+ # Add I_sys parameters, the number of which depends on the number of *total* CS steps (across all
283
+ # calibration sequences).
284
+ for p, s in enumerate(range(steps_per_cs[cs]), start=15):
285
+ param_name = f"I_sys_CS{cs:02n}_step{s:02n}"
286
+ parameter = point_parameters[param_name]
287
+ value = parameter.value
288
+ vary = parameter.vary
289
+ init_value = init_point_parameters[param_name].value
290
+ cs_data[p] = np.array([value, init_value, vary])
291
+
292
+ if step_deficit := steps_per_cs[cs] - longest_cs_num_steps > 0:
293
+ cs_data = np.pad(cs_data, ((0, step_deficit), (0, 0)), constant_values=np.nan)
294
+
295
+ full_output[*idx, cs] = cs_data
296
+
297
+ self.write(
298
+ data=full_output,
299
+ tags=[
300
+ TrendTag.intermediate(),
301
+ TrendTag.arm_id(self.arm_id),
302
+ TrendTag.beam(beam),
303
+ TrendTag.instrument_processing_options(inst_options_name),
304
+ TrendTag.pac_fit_options(fit_options_name),
305
+ TrendTag.task_best_fit_parameters(),
306
+ ],
307
+ encoder=fits_array_encoder,
308
+ )
309
+
310
+ def write_best_fit_demodulation_matrices(
311
+ self, fitter: PolcalFitter, beam: int, inst_options_name: str, fit_options_name: str
312
+ ):
313
+ """
314
+ Write the best-fit demodulation matrices to scratch.
315
+
316
+ The shape will be ``(*FOV_shape, 4, M)``, where FOV_shape is the shape of the input data and M is the number of
317
+ modulation states.
318
+ """
319
+ demod_matrices = fitter.demodulation_matrices
320
+
321
+ self.write(
322
+ data=demod_matrices,
323
+ tags=[
324
+ TrendTag.intermediate(),
325
+ TrendTag.arm_id(self.arm_id),
326
+ TrendTag.beam(beam),
327
+ TrendTag.instrument_processing_options(inst_options_name),
328
+ TrendTag.pac_fit_options(fit_options_name),
329
+ TrendTag.task_best_fit_demodulation_matrices(),
330
+ ],
331
+ encoder=fits_array_encoder,
332
+ )
333
+
334
+ def write_best_fit_flux_and_residuals(
335
+ self, fitter: PolcalFitter, beam: int, inst_options_name: str, fit_options_name: str
336
+ ):
337
+ """Compute the best-fit flux and fit residuals and write them to scratch."""
338
+ fit_container = fitter.local_objects
339
+ TM = fit_container.telescope
340
+ CM = fit_container.calibration_unit
341
+ fov_shape = fit_container.dresser.shape
342
+ num_mod = fit_container.dresser.nummod
343
+ num_steps = fit_container.dresser.numsteps
344
+ num_points = np.prod(fov_shape)
345
+
346
+ flux_array = np.zeros(fov_shape + (num_mod, num_steps))
347
+ residual_array = np.zeros_like(flux_array)
348
+
349
+ for i in range(num_points):
350
+ idx = np.unravel_index(i, fov_shape)
351
+ I_cal, I_unc = fit_container.dresser[idx]
352
+ fit_params = fit_container.fit_parameters[idx]
353
+ modmat = np.zeros((I_cal.shape[0], 4), dtype=np.float64)
354
+ flat_residual = compare_I(
355
+ params=fit_params,
356
+ I_cal=I_cal,
357
+ I_unc=I_unc,
358
+ TM=TM,
359
+ CM=CM,
360
+ modmat=modmat,
361
+ use_M12=True,
362
+ )
363
+ diff = np.reshape(flat_residual, (num_mod, num_steps))
364
+ residual_array[*idx, :, :] = diff
365
+
366
+ flux = diff * I_unc + I_cal
367
+ flux_array[*idx, :, :] = flux
368
+
369
+ self.write(
370
+ data=flux_array,
371
+ tags=[
372
+ TrendTag.intermediate(),
373
+ TrendTag.arm_id(self.arm_id),
374
+ TrendTag.beam(beam),
375
+ TrendTag.instrument_processing_options(inst_options_name),
376
+ TrendTag.pac_fit_options(fit_options_name),
377
+ TrendTag.task_best_fit_flux(),
378
+ ],
379
+ encoder=fits_array_encoder,
380
+ )
381
+ self.write(
382
+ data=residual_array,
383
+ tags=[
384
+ TrendTag.intermediate(),
385
+ TrendTag.arm_id(self.arm_id),
386
+ TrendTag.beam(beam),
387
+ TrendTag.instrument_processing_options(inst_options_name),
388
+ TrendTag.pac_fit_options(fit_options_name),
389
+ TrendTag.task_fit_residuals(),
390
+ ],
391
+ encoder=fits_array_encoder,
392
+ )