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,97 @@
1
+ """Base classes for all Trend science tasks."""
2
+
3
+ from abc import ABC
4
+
5
+ from dkist_processing_common.tasks.base import WorkflowTaskBase
6
+ from dkist_service_configuration.logging import logger
7
+
8
+ from dkist_processing_trend.models.constants import TrendConstants
9
+ from dkist_processing_trend.models.parameters import TrendParameters
10
+
11
+
12
+ class TrendTaskBase(
13
+ WorkflowTaskBase,
14
+ ABC,
15
+ ):
16
+ """
17
+ Task class for base Trend tasks.
18
+
19
+ Parameters
20
+ ----------
21
+ recipe_run_id
22
+ id of the recipe run used to identify the workflow run this task is part of
23
+
24
+ workflow_name
25
+ name of the workflow to which this instance of the task belongs
26
+
27
+ workflow_version
28
+ version of the workflow to which this instance of the task belongs
29
+ """
30
+
31
+ # So tab completion shows all the constants
32
+ constants: TrendConstants
33
+
34
+ @property
35
+ def constants_model_class(self):
36
+ """Get Trend pipeline constants."""
37
+ return TrendConstants
38
+
39
+ def __init__(
40
+ self,
41
+ recipe_run_id: int,
42
+ workflow_name: str,
43
+ workflow_version: str,
44
+ ):
45
+ super().__init__(
46
+ recipe_run_id=recipe_run_id,
47
+ workflow_name=workflow_name,
48
+ workflow_version=workflow_version,
49
+ )
50
+ self.parameters = TrendParameters(
51
+ scratch=self.scratch,
52
+ obs_ip_start_time=self.constants.earliest_ip_start_time,
53
+ instrument=self.constants.instrument,
54
+ )
55
+
56
+
57
+ class TrendArmTaskBase(TrendTaskBase, ABC):
58
+ """
59
+ Task class base for Trend tasks that operate on a single instrument arm.
60
+
61
+ Similar to `TrendTaskBase` except it exposes the `self.arm_id` parameter that returns the arm ID this class was
62
+ instantiated with.
63
+
64
+ Another important feature of this class is that the `run` method will NOT be called if `self.arm_id <arm_id>` is not found
65
+ in `self.constants.arm_id_list <dkist_processing_trend.models.constants.TrendConstants.arm_id_list>` (because data for this arm doesn't exist in the input dataset).
66
+
67
+ Parameters
68
+ ----------
69
+ arm_id
70
+ id of the arm that this task will operate on
71
+
72
+ recipe_run_id
73
+ id of the recipe run used to identify the workflow run this task is part of
74
+
75
+ workflow_name
76
+ name of the workflow to which this instance of the task belongs
77
+
78
+ workflow_version
79
+ version of the workflow to which this instance of the task belongs
80
+ """
81
+
82
+ def __init__(
83
+ self, arm_id: str | int, recipe_run_id: int, workflow_name: str, workflow_version: str
84
+ ):
85
+ super().__init__(
86
+ recipe_run_id=recipe_run_id,
87
+ workflow_name=workflow_name,
88
+ workflow_version=workflow_version,
89
+ )
90
+ self.arm_id = arm_id
91
+
92
+ def pre_run(self) -> None:
93
+ """Check if `self.arm_id <arm_id>` is in the list of arms present in the dataset and run `run` if so."""
94
+ super().pre_run()
95
+ if self.arm_id not in self.constants.arm_id_list:
96
+ logger.info(f"This dataset has no data for arm {self.arm_id}. Nothing to do.")
97
+ self.run = lambda: None
@@ -0,0 +1,167 @@
1
+ """Task for transferring trend data from scratch to datacenter stores."""
2
+
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from typing import Iterable
6
+
7
+ from dkist_processing_common.models.message import CatalogFrameMessage
8
+ from dkist_processing_common.models.message import CatalogFrameMessageBody
9
+ from dkist_processing_common.tasks.mixin.globus import GlobusMixin
10
+ from dkist_processing_common.tasks.mixin.interservice_bus import InterserviceBusMixin
11
+ from dkist_processing_common.tasks.output_data_base import OutputDataBase
12
+ from dkist_processing_common.tasks.output_data_base import TransferDataBase
13
+ from dkist_service_configuration.logging import logger
14
+
15
+ from dkist_processing_trend.models.constants import TrendConstants
16
+
17
+ __all__ = ["TransferTrendData", "PublishTrendCatalogMessages"]
18
+
19
+
20
+ class TrendOutputDataBase(OutputDataBase):
21
+ """
22
+ Base class that defines the destination folders for trend data.
23
+
24
+ Parameters
25
+ ----------
26
+ recipe_run_id
27
+ id of the recipe run used to identify the workflow run this task is part of
28
+
29
+ workflow_name
30
+ name of the workflow to which this instance of the task belongs
31
+
32
+ workflow_version
33
+ version of the workflow to which this instance of the task belongs
34
+ """
35
+
36
+ # So tab completion shows all the constants
37
+ constants: TrendConstants
38
+
39
+ @property
40
+ def constants_model_class(self):
41
+ """Define the constants class used to access the constants db."""
42
+ return TrendConstants
43
+
44
+ @property
45
+ def destination_root_folder(self) -> Path:
46
+ """
47
+ Define the root folder.
48
+
49
+ "trend/polcal"
50
+ """
51
+ return Path("trend") / "polcal"
52
+
53
+ @property
54
+ def destination_folder(self) -> Path:
55
+ """
56
+ Define the destination folder for this trend run.
57
+
58
+ "trend/polcal/{EARLIEST_IP_START_TIME}/{INSTRUMENT}"
59
+ """
60
+ # E.g., 1999-01-02T12:34:56.12352 -> 19990102T123456
61
+ formatted_date = datetime.fromisoformat(self.constants.earliest_ip_start_time).strftime(
62
+ "%Y%m%dT%H%M%S"
63
+ )
64
+ return self.destination_root_folder / formatted_date / self.constants.instrument
65
+
66
+
67
+ class TransferTrendData(TrendOutputDataBase, TransferDataBase, GlobusMixin):
68
+ """
69
+ Task class for transferring processed trend data to the object store.
70
+
71
+ Parameters
72
+ ----------
73
+ recipe_run_id
74
+ id of the recipe run used to identify the workflow run this task is part of
75
+
76
+ workflow_name
77
+ name of the workflow to which this instance of the task belongs
78
+
79
+ workflow_version
80
+ version of the workflow to which this instance of the task belongs
81
+ """
82
+
83
+ def transfer_objects(self):
84
+ """Transfer output frames."""
85
+ with self.telemetry_span("Upload output frames"):
86
+ self.transfer_output_frames()
87
+
88
+ def transfer_output_frames(self):
89
+ """Create a Globus transfer for all output data, as well as any available dataset extras."""
90
+ output_transfer_items = self.build_output_frame_transfer_list()
91
+
92
+ logger.info(
93
+ f"Preparing globus transfer {len(output_transfer_items)} items. "
94
+ f"recipe_run_id={self.recipe_run_id}. "
95
+ f"transfer_items={output_transfer_items[:3]}..."
96
+ )
97
+
98
+ self.globus_transfer_scratch_to_object_store(
99
+ transfer_items=output_transfer_items,
100
+ label=f"Transfer trend output frames for recipe_run_id {self.recipe_run_id}",
101
+ )
102
+
103
+
104
+ class PublishTrendCatalogMessages(TrendOutputDataBase, InterserviceBusMixin):
105
+ """
106
+ Task class for publishing catalog messages related to the frames transferred to the object store.
107
+
108
+ Parameters
109
+ ----------
110
+ recipe_run_id
111
+ id of the recipe run used to identify the workflow run this task is part of
112
+
113
+ workflow_name
114
+ name of the workflow to which this instance of the task belongs
115
+
116
+ workflow_version
117
+ version of the workflow to which this instance of the task belongs
118
+ """
119
+
120
+ def run(self) -> None:
121
+ """Run method for this task."""
122
+ with self.telemetry_span("Gather output data"):
123
+ frames = self.read(tags=self.output_frame_tags)
124
+
125
+ with self.telemetry_span("Create message objects"):
126
+ messages = self.frame_messages(paths=frames)
127
+ frame_message_count = len(messages)
128
+
129
+ with self.telemetry_span(f"Publish messages: {frame_message_count = }"):
130
+ self.interservice_bus_publish(messages=messages)
131
+
132
+ def frame_messages(self, paths: Iterable[Path]) -> list[CatalogFrameMessage]:
133
+ """
134
+ Create the frame messages.
135
+
136
+ Parameters
137
+ ----------
138
+ paths
139
+ The input paths for which to publish frame messages
140
+ folder_modifier
141
+ A subdirectory to use if the files in paths are not in the base directory
142
+
143
+ Returns
144
+ -------
145
+ A list of frame messages
146
+ """
147
+ message_bodies = [
148
+ CatalogFrameMessageBody(
149
+ objectName=self.format_object_key(path=p),
150
+ conversationId=str(self.recipe_run_id),
151
+ bucket=self.destination_bucket,
152
+ )
153
+ for p in paths
154
+ ]
155
+ messages = [CatalogFrameMessage(body=body) for body in message_bodies]
156
+ return messages
157
+
158
+ def rollback(self):
159
+ """
160
+ Warn that the metadata-store and the interservice bus retain the effect of this tasks execution.
161
+
162
+ Rolling back this task may not be achievable without other action.
163
+ """
164
+ super().rollback()
165
+ logger.warning(
166
+ f"Modifications to the metadata store and the interservice bus were not rolled back."
167
+ )
@@ -0,0 +1,6 @@
1
+ """Package for tasks specific to ViSP data."""
2
+
3
+ from dkist_processing_trend.tasks.visp.visp_dmpd import *
4
+ from dkist_processing_trend.tasks.visp.visp_geometric import *
5
+ from dkist_processing_trend.tasks.visp.visp_prep_fit_data import *
6
+ from dkist_processing_trend.tasks.visp.visp_process_demod import *
@@ -0,0 +1,405 @@
1
+ """Task for producing ViSP Demodulated PolcalData (DMPD)."""
2
+
3
+ from dataclasses import dataclass
4
+ from functools import cached_property
5
+
6
+ import numpy as np
7
+ from dkist_processing_common.codecs.asdf import asdf_decoder
8
+ from dkist_processing_common.codecs.fits import fits_array_decoder
9
+ from dkist_processing_common.codecs.fits import fits_array_encoder
10
+ from dkist_processing_math.arithmetic import divide_arrays_by_array
11
+ from dkist_processing_math.arithmetic import subtract_array_from_arrays
12
+ from dkist_processing_math.linear_algebra import nd_left_matrix_multiply
13
+ from dkist_processing_math.statistics import average_numpy_arrays
14
+ from dkist_service_configuration.logging import logger
15
+ from scipy.ndimage import affine_transform
16
+
17
+ from dkist_processing_trend.models.fit_options import FitOptions
18
+ from dkist_processing_trend.models.instrument_options import VispInstrumentOptions
19
+ from dkist_processing_trend.models.tags import TrendTag
20
+ from dkist_processing_trend.tasks.trend_base import TrendArmTaskBase
21
+ from dkist_processing_trend.tasks.visp.visp_extract_beam import extract_visp_beam
22
+
23
+ __all__ = ["VispDemodulatedPolcalData"]
24
+
25
+
26
+ @dataclass
27
+ class VispCalibrationCollection:
28
+ """Dataclass to hold all calibration objects needed for ViSP "science" calibration."""
29
+
30
+ dark: np.ndarray
31
+ gain: np.ndarray
32
+ angle: dict
33
+ shift: dict
34
+ demod_matrices: dict
35
+
36
+ @cached_property
37
+ def beams_overlap_slice(self) -> tuple[slice, slice]:
38
+ """
39
+ Compute array slices that will extract the largest region with overlap from both beams.
40
+
41
+ This is done by considering the shifts computed by the GeometricCalibration task. Any sub-pixel
42
+ overlaps are rounded to the next integer that still guarantees overlap.
43
+
44
+ When "start pixels" are mentioned, those are pixels being counted from zero on a given axis in the positive direction.
45
+ When "end pixels" are mentioned, those are pixels being counted from the end of a given axis in the negative direction.
46
+ """
47
+ logger.info("Computing beam overlap slices")
48
+ # This will be a flat list of (x, y) pairs for both beams
49
+ flat_shifts = list(self.shift.values())
50
+ # Split out into an x list and a y list
51
+ all_x_shifts, all_y_shifts = zip(*flat_shifts)
52
+ all_x_shifts = np.array(all_x_shifts)
53
+ all_y_shifts = np.array(all_y_shifts)
54
+
55
+ logger.info(f"All x shifts: {all_x_shifts}")
56
+ logger.info(f"All y shifts: {all_y_shifts}")
57
+
58
+ # The amount we need to "slice in" from the start of the array is equivalent to the absolute value of the most negative shift.
59
+ # The call to `np.ceil` ensures that the integer rounding doesn't allow non-overlap regions to leak in.
60
+ start_pixels_to_slice_x = int(np.ceil(abs(np.min(all_x_shifts))))
61
+ start_pixels_to_slice_y = int(np.ceil(abs(np.min(all_y_shifts))))
62
+
63
+ # The amount we need to "chop off" the end of the array is the most positive shift.
64
+ #
65
+ # Here we rely on the fact that the fiducial array's shift is *always* (0, 0)
66
+ # (see `geometric.compute_modstate_offset`). Thus, if there are no negative shifts then the following lines
67
+ # will result in None. This is required for slicing because array[x:0] is no good. So if the max is 0 then we
68
+ # end up with array[x:None] which goes all the way to the end of the array.
69
+ #
70
+ # The call to `np.ceil` ensures that the integer rounding doesn't allow non-overlap regions to leak in.
71
+ # (because more negative slices will cut out more data).
72
+ end_pixels_to_slice_x = int(np.ceil(np.max(all_x_shifts))) or None
73
+ end_pixels_to_slice_y = int(np.ceil(np.max(all_y_shifts))) or None
74
+
75
+ # As the pixels to remove from the end of axes is given as a positive number, we need to make it negative for slicing.
76
+ if end_pixels_to_slice_x is not None:
77
+ end_pixels_to_slice_x *= -1
78
+
79
+ if end_pixels_to_slice_y is not None:
80
+ end_pixels_to_slice_y *= -1
81
+
82
+ # Construct the slices
83
+ x_slice = slice(start_pixels_to_slice_x, end_pixels_to_slice_x)
84
+ y_slice = slice(start_pixels_to_slice_y, end_pixels_to_slice_y)
85
+
86
+ logger.info(f"Trimming non-overlapping beam edges by ({x_slice}, {y_slice})")
87
+ return x_slice, y_slice
88
+
89
+
90
+ class VispDemodulatedPolcalData(TrendArmTaskBase):
91
+ """
92
+ Task class for producing demodulated polcal data (DMPD) for ViSP.
93
+
94
+ Demodulated polcal data is essentially a "science" reduction of the input POLCAL data.
95
+
96
+ Parameters
97
+ ----------
98
+ arm_id
99
+ id of the instrument arm to operate on
100
+
101
+ recipe_run_id
102
+ id of the recipe run used to identify the workflow run this task is part of
103
+
104
+ workflow_name
105
+ name of the workflow to which this instance of the task belongs
106
+
107
+ workflow_version
108
+ version of the workflow to which this instance of the task belongs
109
+ """
110
+
111
+ record_provenance = True
112
+
113
+ def run(self):
114
+ """
115
+ Loop over all PAC and instrument options and produce a set of demodulated polcal data for each.
116
+
117
+ The "set" includes data for each beam prior to combination and then a final array for the combined data.
118
+
119
+ Generally, the algorithm for each set of PAC and instrument options is:
120
+
121
+ #. Collect all calibration objects needed (dark, gain, geometric, demodulation matrices)
122
+ #. Apply dark and gain corrections
123
+ #. Split by beam and demodulated
124
+ #. Apply geometric corrections
125
+ #. Combined beams
126
+ #. Save each beam's output and the combined output
127
+ """
128
+ for fit_options in self.parameters.fit_options_list:
129
+ for inst_options in self.parameters.instrument_processing_options:
130
+ log_str = f"fit option {fit_options.name} and instrument option {inst_options.name}"
131
+
132
+ logger.info(f"Collecting calibration objects for {log_str}")
133
+ calibrations = self.collect_calibration_objects(
134
+ fit_options=fit_options, instrument_options=inst_options
135
+ )
136
+ beam_array = extract_visp_beam(
137
+ calibrations.gain, beam=1, beam_border=self.parameters.visp_beam_border
138
+ )
139
+ beam_shape = beam_array.shape
140
+ x_slice, y_slice = calibrations.beams_overlap_slice
141
+ cut_shape = beam_array[x_slice, y_slice].shape
142
+
143
+ with self.telemetry_span(f"Processing {self.constants.num_cs_steps} CS steps"):
144
+ beam1_full_dmpd = np.full(
145
+ (1, self.constants.num_cs_steps, 4, *cut_shape), np.nan
146
+ )
147
+ beam2_full_dmpd = np.full_like(beam1_full_dmpd, np.nan)
148
+ combined_full_dmpd = np.full_like(beam1_full_dmpd, np.nan)
149
+ for cs_step in range(self.constants.num_cs_steps):
150
+ logger.info(
151
+ f"Processing demodulated polcal data for {cs_step = } with {log_str}"
152
+ )
153
+ beam1_dmpd, beam2_dmpd, combined_dmpd = self.process_single_cs_step(
154
+ cs_step=cs_step, calibrations=calibrations, beam_shape=beam_shape
155
+ )
156
+
157
+ # np.moveaxis here because the stokes axis is the last axis in `self.process_single_cs_step`
158
+ # (to make math easier), but we want the DMPD outputs to have stokes before the array axes.
159
+ beam1_full_dmpd[0, cs_step, :, :, :] = np.moveaxis(beam1_dmpd, -1, 0)
160
+ beam2_full_dmpd[0, cs_step, :, :, :] = np.moveaxis(beam2_dmpd, -1, 0)
161
+ combined_full_dmpd[0, cs_step, :, :, :] = np.moveaxis(combined_dmpd, -1, 0)
162
+
163
+ self.write_dmpd(
164
+ beam1_full_dmpd,
165
+ beam=1,
166
+ fit_options=fit_options,
167
+ instrument_options=inst_options,
168
+ )
169
+ self.write_dmpd(
170
+ beam2_full_dmpd,
171
+ beam=2,
172
+ fit_options=fit_options,
173
+ instrument_options=inst_options,
174
+ )
175
+ self.write_dmpd(
176
+ beam1_full_dmpd,
177
+ fit_options=fit_options,
178
+ instrument_options=inst_options,
179
+ beam=None,
180
+ )
181
+
182
+ def collect_calibration_objects(
183
+ self, fit_options: FitOptions, instrument_options: VispInstrumentOptions
184
+ ) -> VispCalibrationCollection:
185
+ """Collect the INTERMEDIATE calibration objects needed to correct demodulated polcal data."""
186
+ angle_dict = dict()
187
+ shift_dict = dict()
188
+ demod_dict = dict()
189
+
190
+ base_tags = [TrendTag.intermediate(), TrendTag.arm_id(self.arm_id)]
191
+
192
+ # Dark
193
+ ######
194
+ full_dark_array = next(
195
+ self.read(
196
+ tags=base_tags + [TrendTag.frame(), TrendTag.task_dark()],
197
+ decoder=fits_array_decoder,
198
+ )
199
+ )
200
+
201
+ # Gain
202
+ #######
203
+ full_gain_array = next(
204
+ self.read(
205
+ tags=base_tags + [TrendTag.frame(), TrendTag.task_gain()],
206
+ decoder=fits_array_decoder,
207
+ )
208
+ )
209
+
210
+ for beam in [1, 2]:
211
+ beam_tags = base_tags + [TrendTag.beam(beam)]
212
+
213
+ # Geo
214
+ #######
215
+ geo_dict = next(
216
+ self.read(
217
+ tags=beam_tags + [TrendTag.task_visp_geometric_calibration()],
218
+ decoder=asdf_decoder,
219
+ )
220
+ )
221
+ angle_dict[TrendTag.beam(beam)] = geo_dict["angle_rad"]
222
+ shift_dict[TrendTag.beam(beam)] = geo_dict["shift"]
223
+
224
+ # Demod
225
+ #######
226
+ demod_matrices = next(
227
+ self.read(
228
+ tags=beam_tags
229
+ + [
230
+ TrendTag.pac_fit_options(fit_options.name),
231
+ TrendTag.instrument_processing_options(instrument_options.name),
232
+ TrendTag.task_processed_demodulation_matrices(),
233
+ ],
234
+ decoder=fits_array_decoder,
235
+ )
236
+ )
237
+ demod_dict[TrendTag.beam(beam)] = demod_matrices
238
+
239
+ return VispCalibrationCollection(
240
+ dark=full_dark_array,
241
+ gain=full_gain_array,
242
+ angle=angle_dict,
243
+ shift=shift_dict,
244
+ demod_matrices=demod_dict,
245
+ )
246
+
247
+ def process_single_cs_step(
248
+ self,
249
+ cs_step: int,
250
+ calibrations: VispCalibrationCollection,
251
+ beam_shape: tuple[int, int],
252
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
253
+ """
254
+ Fully process the data for a single CS step.
255
+
256
+ To simplify the code, the dark and gain corrections are done on the full-frame before beam extraction.
257
+ Demodulation and geometric corrections are applied on a per-beam basis.
258
+
259
+ All outputs are trimmed to only include the regions that exist in both beams.
260
+
261
+ Returns
262
+ -------
263
+ The corrected beam 1 array
264
+ The corrected beam 2 array
265
+ The corrected combined array
266
+ """
267
+ modstate_full_frame_data_list = []
268
+ for modstate in range(1, self.constants.num_modstates + 1):
269
+ tags = [
270
+ TrendTag.input(),
271
+ TrendTag.frame(),
272
+ TrendTag.arm_id(self.arm_id),
273
+ TrendTag.task_polcal(),
274
+ TrendTag.cs_step(cs_step),
275
+ TrendTag.modstate(modstate),
276
+ ]
277
+ input_arrays = self.read(tags=tags, decoder=fits_array_decoder)
278
+ avg_input = average_numpy_arrays(input_arrays)
279
+
280
+ dark_corrected = subtract_array_from_arrays(
281
+ arrays=avg_input, array_to_subtract=calibrations.dark
282
+ )
283
+ gain_corrected = next(
284
+ divide_arrays_by_array(arrays=dark_corrected, array_to_divide_by=calibrations.gain)
285
+ )
286
+
287
+ modstate_full_frame_data_list.append(gain_corrected)
288
+
289
+ final_beam_data_list = []
290
+ for beam in [1, 2]:
291
+ demod_matrices = calibrations.demod_matrices[TrendTag.beam(beam)]
292
+ beam_stack = np.full(beam_shape + (self.constants.num_modstates,), np.nan)
293
+ for modstate in range(1, self.constants.num_modstates + 1):
294
+ full_frame_data = modstate_full_frame_data_list[modstate - 1]
295
+ beam_data = extract_visp_beam(
296
+ full_frame_data, beam=beam, beam_border=self.parameters.visp_beam_border
297
+ )
298
+ beam_stack[:, :, modstate - 1] = beam_data
299
+
300
+ demodulated_data = nd_left_matrix_multiply(
301
+ vector_stack=beam_stack,
302
+ matrix_stack=demod_matrices,
303
+ )
304
+
305
+ geo_corrected = np.full_like(demodulated_data, np.nan)
306
+ for s in range(demodulated_data.shape[-1]):
307
+ geo_corrected[:, :, s] = self.correct_geometry(
308
+ demodulated_data[:, :, s],
309
+ angle=calibrations.angle[TrendTag.beam(beam)],
310
+ shift=calibrations.shift[TrendTag.beam(beam)],
311
+ )
312
+
313
+ x_slice, y_slice = calibrations.beams_overlap_slice
314
+ cut_array = geo_corrected[x_slice, y_slice, :]
315
+
316
+ final_beam_data_list.append(cut_array)
317
+
318
+ combined_data = self.combine_beams(
319
+ beam1_data=final_beam_data_list[0], beam2_data=final_beam_data_list[1]
320
+ )
321
+
322
+ return final_beam_data_list[0], final_beam_data_list[1], combined_data
323
+
324
+ def combine_beams(self, beam1_data: np.ndarray, beam2_data: np.ndarray) -> np.ndarray:
325
+ r"""
326
+ Combine polarimetric beams so that polarization states are normalized by the intensity state (Stokes I).
327
+
328
+ In other words:
329
+
330
+ .. math::
331
+
332
+ I_{avg} & = (I_1 + I_2) / 2
333
+
334
+ Q_{avg} & = I_{avg} (Q_1 / I_1 + Q_2 / I_2) / 2
335
+
336
+ ...and the same for U and V
337
+ """
338
+ avg_data = np.zeros_like(beam1_data)
339
+
340
+ # Rely on the fact that the Stokes states are in order after demodulation
341
+ avg_I = (beam1_data[:, :, 0] + beam2_data[:, :, 0]) / 2.0
342
+ avg_data[:, :, 0] = avg_I
343
+
344
+ for stokes in range(1, 4):
345
+ beam1_norm = beam1_data[:, :, stokes] / beam1_data[:, :, 0]
346
+ beam2_norm = beam2_data[:, :, stokes] / beam2_data[:, :, 0]
347
+ avg_data[:, :, stokes] = avg_I * (beam1_norm + beam2_norm) / 2.0
348
+
349
+ return avg_data
350
+
351
+ def correct_geometry(
352
+ self, array: np.ndarray, angle: float, shift: tuple[float, float]
353
+ ) -> np.ndarray:
354
+ """
355
+ Rotate and shift an array.
356
+
357
+ Note that the input ``angle`` and ``shift`` arguments are the *amount to rotate/shift by*. They are passed straight
358
+ through to the affine transform.
359
+ """
360
+ array_shape = array.shape
361
+ cosine_angle = np.cos(angle)
362
+ sine_angle = np.sin(angle)
363
+ inverse_rotation_matrix = np.array(
364
+ [[cosine_angle, -sine_angle], [sine_angle, cosine_angle]],
365
+ dtype=np.float64,
366
+ )
367
+
368
+ rotation_center = 0.5 * (np.asarray(array_shape, dtype=np.float64) - 1.0)
369
+ transform_offset = rotation_center + shift - inverse_rotation_matrix @ rotation_center
370
+
371
+ corrected_array = affine_transform(
372
+ array,
373
+ matrix=inverse_rotation_matrix,
374
+ offset=transform_offset,
375
+ order=5,
376
+ mode="nearest",
377
+ cval=np.nan,
378
+ prefilter=True,
379
+ )
380
+ return corrected_array
381
+
382
+ def write_dmpd(
383
+ self,
384
+ array: np.ndarray,
385
+ beam: int | None,
386
+ fit_options: FitOptions,
387
+ instrument_options: VispInstrumentOptions,
388
+ ) -> None:
389
+ """
390
+ Write a demodulated polcal data output to scratch.
391
+
392
+ If ``beam`` is not `None` then the output will be tagged as a `~dkist_processing_trend.models.tags.TrendTag.task_single_beam_demodulated_polcal_data`,
393
+ otherwise the output is tagged with `~dkist_processing_trend.models.tags.TrendTag.task_demodulated_polcal_data`.
394
+ """
395
+ tags = [
396
+ TrendTag.intermediate(),
397
+ TrendTag.arm_id(self.arm_id),
398
+ TrendTag.pac_fit_options(fit_options.name),
399
+ TrendTag.instrument_processing_options(instrument_options.name),
400
+ ]
401
+ if beam is not None:
402
+ tags += [TrendTag.beam(beam), TrendTag.task_single_beam_demodulated_polcal_data()]
403
+ else:
404
+ tags.append(TrendTag.task_demodulated_polcal_data())
405
+ self.write(data=array, tags=tags, encoder=fits_array_encoder)
@@ -0,0 +1,14 @@
1
+ """Function to split apart the two ViSP beams."""
2
+
3
+ from typing import Literal
4
+
5
+ import numpy as np
6
+
7
+
8
+ def extract_visp_beam(array: np.ndarray, beam: Literal[1, 2], beam_border: int) -> np.ndarray:
9
+ """Extract a single beam from a raw, dual-beam array."""
10
+ if beam == 1:
11
+ return np.copy(array[:beam_border, ...])
12
+ if beam == 2:
13
+ return np.copy(array[beam_border:, ...][::-1, :])
14
+ raise ValueError(f"ViSP only has beams 1 and 2. Can't extract data for beam '{beam}'.")