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,874 @@
1
+ from functools import partial
2
+
3
+ import numpy as np
4
+ import pytest
5
+ import scipy.ndimage as spnd
6
+ from astropy.io import fits
7
+ from astropy.modeling.functional_models import Voigt1D
8
+ from dkist_processing_common._util.scratch import WorkflowFileSystem
9
+ from dkist_processing_common.codecs.asdf import asdf_decoder
10
+ from dkist_processing_common.codecs.asdf import asdf_encoder
11
+ from dkist_processing_common.codecs.fits import fits_array_decoder
12
+ from dkist_processing_common.codecs.fits import fits_array_encoder
13
+
14
+ from dkist_processing_trend.models.instrument import InstrumentName
15
+ from dkist_processing_trend.models.instrument_options import VispInstrumentOptions
16
+ from dkist_processing_trend.models.parameters import TrendParameters
17
+ from dkist_processing_trend.models.tags import TrendTag
18
+ from dkist_processing_trend.tasks.visp.visp_dmpd import VispCalibrationCollection
19
+ from dkist_processing_trend.tasks.visp.visp_dmpd import VispDemodulatedPolcalData
20
+ from dkist_processing_trend.tasks.visp.visp_extract_beam import extract_visp_beam
21
+ from dkist_processing_trend.tasks.visp.visp_geometric import VispGeometricCalibration
22
+ from dkist_processing_trend.tasks.visp.visp_prep_fit_data import VispPrepareFitData
23
+ from dkist_processing_trend.tasks.visp.visp_process_demod import VispProcessDemodulationMatrices
24
+ from dkist_processing_trend.tests.conftest import ModulatedCSStepHeaders
25
+ from dkist_processing_trend.tests.conftest import TrendInputDatasetParameterValues
26
+ from dkist_processing_trend.tests.conftest import TrendTestingConstantsDatabase
27
+ from dkist_processing_trend.tests.conftest import write_calibration_sequence_frames_to_task
28
+
29
+
30
+ def test_extract_beam():
31
+ """
32
+ Given: A task based on `VispTrendTaskBase` and an array
33
+ When: Extracting the ViSP beams from the array
34
+ Then: The expected array is returned
35
+ """
36
+ small_beam_border = 10
37
+ array = np.empty((small_beam_border * 3, 4))
38
+ beam2_signal = np.arange(array.shape[0] - small_beam_border)
39
+ array[:small_beam_border, :] = 1.1111
40
+ array[small_beam_border:, :] = beam2_signal[::-1][:, None]
41
+
42
+ beam1 = extract_visp_beam(array=array, beam=1, beam_border=small_beam_border)
43
+ np.testing.assert_array_equal(beam1, 1.1111)
44
+
45
+ beam2 = extract_visp_beam(array=array, beam=2, beam_border=small_beam_border)
46
+ np.testing.assert_array_equal(np.mean(beam2, axis=1), beam2_signal)
47
+ np.testing.assert_array_equal(np.std(beam2, axis=1), 0.0)
48
+
49
+ with pytest.raises(
50
+ ValueError, match="ViSP only has beams 1 and 2. Can't extract data for beam '3'."
51
+ ):
52
+ extract_visp_beam(array=array, beam=3, beam_border=small_beam_border)
53
+
54
+
55
+ @pytest.fixture
56
+ def geometric_task_beam_shape() -> tuple[int, int]:
57
+ return (700, 900)
58
+
59
+
60
+ @pytest.fixture
61
+ def tiny_calibration_sequence() -> tuple[list, list, list, list, list]:
62
+ # We want some clear steps and at least one non-clear step
63
+ pol_status = [
64
+ "clear",
65
+ "Sapphire Polarizer",
66
+ "clear",
67
+ ]
68
+ pol_theta = [0.0, 120.0, 0.0]
69
+ ret_status = ["clear", "SiO2 SAR", "clear"]
70
+ ret_theta = [0.0, 0.0, 0.0]
71
+ dark_status = [
72
+ "FieldStop (5arcmin)",
73
+ "FieldStop (5arcmin)",
74
+ "FieldStop (5arcmin)",
75
+ ]
76
+
77
+ return pol_status, pol_theta, ret_status, ret_theta, dark_status
78
+
79
+
80
+ @pytest.fixture
81
+ def visp_geometric_task(
82
+ tmp_path,
83
+ recipe_run_id,
84
+ geometric_task_beam_shape,
85
+ link_constants_db,
86
+ assign_input_dataset_doc_to_task,
87
+ ):
88
+ link_constants_db(recipe_run_id, TrendTestingConstantsDatabase(INSTRUMENT=InstrumentName.visp))
89
+
90
+ with VispGeometricCalibration(
91
+ arm_id=1, recipe_run_id=recipe_run_id, workflow_name="name", workflow_version="version"
92
+ ) as task:
93
+ task.scratch = WorkflowFileSystem(recipe_run_id=recipe_run_id, scratch_base_path=tmp_path)
94
+ assign_input_dataset_doc_to_task(
95
+ task=task,
96
+ parameter_values=TrendInputDatasetParameterValues(
97
+ trend_visp_beam_border=geometric_task_beam_shape[0]
98
+ ),
99
+ )
100
+ task.parameters = TrendParameters(
101
+ scratch=task.scratch,
102
+ obs_ip_start_time=task.constants.earliest_ip_start_time,
103
+ instrument=InstrumentName.visp,
104
+ )
105
+
106
+ yield task
107
+ task._purge()
108
+
109
+
110
+ def make_polcal_data_for_geo_task(
111
+ frame: ModulatedCSStepHeaders,
112
+ offset: tuple[float, float],
113
+ angles_deg: tuple[float, float],
114
+ ) -> np.ndarray:
115
+ full_shape = frame.array_shape[1:]
116
+ if (
117
+ frame.pol_status != "clear"
118
+ or frame.ret_status != "clear"
119
+ or frame.dark_status == "DarkShutter"
120
+ ):
121
+ # Put trash in the non-clear frames so we'll crash hard if they're used
122
+ return np.full(full_shape, np.nan)
123
+
124
+ beam_shape = (full_shape[0] // 2, full_shape[1])
125
+
126
+ # Spectral line centers
127
+ line_centers = np.array([0.2, 0.4, 0.5, 0.8]) * beam_shape[0]
128
+ offset_line_centers = line_centers - offset[0]
129
+
130
+ # Hairline centers
131
+ hairlines = [50, beam_shape[1] - 120]
132
+
133
+ beam1_data = np.full(beam_shape, 100.0)
134
+ # Approximate absorption lines with a set of Voigt profiles
135
+ beam1_voigt_model = sum(
136
+ [
137
+ Voigt1D(x_0=c, amplitude_L=2 - 0.8 * abs(i - 2), fwhm_G=4, fwhm_L=2)
138
+ for i, c in enumerate(line_centers[1:], start=1)
139
+ ],
140
+ Voigt1D(x_0=line_centers[0], amplitude_L=0.4),
141
+ )
142
+ beam1_voight = beam1_voigt_model(np.arange(beam_shape[0]))
143
+ # Normalize so the strongest line imparts a 50% reduction in flux
144
+ beam1_voight = beam1_voight / beam1_voight.max() * 2 + 1
145
+ beam1_data /= beam1_voight[:, None]
146
+ for h in hairlines:
147
+ # Add hairlines at 10% the continuum
148
+ beam1_data[:, h : h + 5] *= 0.1
149
+
150
+ beam1_data = spnd.rotate(beam1_data, angle=angles_deg[0], reshape=False, mode="nearest")
151
+
152
+ # Same for beam2, but with offsets for line centers and hairlines
153
+ beam2_data = np.full(beam_shape, 200.0)
154
+ beam2_voigt_model = sum(
155
+ [
156
+ Voigt1D(x_0=c, amplitude_L=2 - 0.8 * abs(i - 2), fwhm_G=4, fwhm_L=2)
157
+ for i, c in enumerate(offset_line_centers[1:], start=1)
158
+ ],
159
+ Voigt1D(x_0=offset_line_centers[0], amplitude_L=0.4),
160
+ )
161
+ beam2_voight = beam2_voigt_model(np.arange(beam_shape[0]))
162
+ beam2_voight = beam2_voight / beam2_voight.max() * 2 + 1
163
+ beam2_data /= beam2_voight[:, None]
164
+ for h in hairlines:
165
+ b2_h = h - int(offset[1])
166
+ beam2_data[:, b2_h : b2_h + 5] *= 0.1
167
+
168
+ beam2_data = spnd.rotate(beam2_data, angle=angles_deg[1], reshape=False, mode="nearest")
169
+
170
+ full_frame_array = np.vstack([beam1_data, beam2_data[::-1, :]])
171
+
172
+ return full_frame_array
173
+
174
+
175
+ def test_visp_geometric_task(
176
+ visp_geometric_task,
177
+ geometric_task_beam_shape,
178
+ tiny_calibration_sequence,
179
+ mocker,
180
+ fake_gql_client,
181
+ ):
182
+ """
183
+ Given: A VispGeometricCalibration task with INPUT polcal data that contains clear measurements with a known offset between the beams
184
+ When: Running the task
185
+ Then: The correct output is written and the computed shift matches what is expected
186
+ """
187
+ mocker.patch(
188
+ "dkist_processing_common.tasks.mixin.metadata_store.GraphQLClient", new=fake_gql_client
189
+ )
190
+
191
+ task = visp_geometric_task
192
+ pol_status, pol_theta, ret_status, ret_theta, dark_status = tiny_calibration_sequence
193
+ full_shape = (geometric_task_beam_shape[0] * 2, geometric_task_beam_shape[1])
194
+ offset = (23.2, 6.28)
195
+ angles_deg = (0.005, -0.01)
196
+ data_func = partial(make_polcal_data_for_geo_task, offset=offset, angles_deg=angles_deg)
197
+
198
+ write_calibration_sequence_frames_to_task(
199
+ task=task,
200
+ instrument=InstrumentName.visp,
201
+ arm_id=1,
202
+ wavelength=633.6,
203
+ pol_status=pol_status,
204
+ pol_theta=pol_theta,
205
+ ret_status=ret_status,
206
+ ret_theta=ret_theta,
207
+ dark_status=dark_status,
208
+ array_shape=full_shape,
209
+ data_func=data_func,
210
+ tags=[TrendTag.input(), TrendTag.arm_id(1)],
211
+ )
212
+
213
+ task()
214
+
215
+ for b, beam in enumerate([1, 2]):
216
+ tags = [
217
+ TrendTag.intermediate(),
218
+ TrendTag.arm_id(1),
219
+ TrendTag.beam(beam),
220
+ TrendTag.task_visp_geometric_calibration(),
221
+ ]
222
+ assert task.count(tags) == 1
223
+ geo_dict = next(task.read(tags=tags, decoder=asdf_decoder))
224
+
225
+ # Check that the shift is correct to within 1/2 pixel, which is acceptable for Trend stuff
226
+ computed_shift = geo_dict["shift"]
227
+ expected_offset = (0, 0) if beam == 1 else -1 * np.array(offset)
228
+ np.testing.assert_array_less(np.abs(computed_shift - expected_offset), 0.5)
229
+
230
+ # Check that the angles are correct to within 1%, which I mostly just made up based on real data
231
+ computed_angle = geo_dict["angle_rad"]
232
+ np.testing.assert_array_less(
233
+ np.abs(np.rad2deg(computed_angle) - angles_deg[b]) / angles_deg[b], 0.01
234
+ )
235
+
236
+
237
+ @pytest.fixture
238
+ def single_visp_instrument_options_list() -> list[dict]:
239
+ return [
240
+ {
241
+ "name": "visp_options",
242
+ "num_spatial_px": 10,
243
+ "num_spectral_px": 1,
244
+ "spatial_smoothing_fit_order": 3,
245
+ }
246
+ ]
247
+
248
+
249
+ @pytest.fixture
250
+ def small_beam_border() -> int:
251
+ return 100
252
+
253
+
254
+ @pytest.fixture
255
+ def visp_prep_fit_data_task(
256
+ recipe_run_id,
257
+ tmp_path,
258
+ link_constants_db,
259
+ assign_input_dataset_doc_to_task,
260
+ single_visp_instrument_options_list,
261
+ small_num_cs_steps,
262
+ small_beam_border,
263
+ ):
264
+ link_constants_db(
265
+ recipe_run_id,
266
+ TrendTestingConstantsDatabase(
267
+ INSTRUMENT=InstrumentName.visp,
268
+ ARM_ID_LIST=[1, 3],
269
+ NUM_CS_STEPS=small_num_cs_steps,
270
+ ),
271
+ )
272
+ with VispPrepareFitData(
273
+ arm_id=3, recipe_run_id=recipe_run_id, workflow_name="name", workflow_version="version"
274
+ ) as task:
275
+ task.scratch = WorkflowFileSystem(recipe_run_id=recipe_run_id, scratch_base_path=tmp_path)
276
+ assign_input_dataset_doc_to_task(
277
+ task,
278
+ TrendInputDatasetParameterValues(
279
+ trend_instrument_processing_options_visp=single_visp_instrument_options_list,
280
+ trend_visp_beam_border=small_beam_border,
281
+ ),
282
+ )
283
+ task.parameters = TrendParameters(
284
+ scratch=task.scratch,
285
+ obs_ip_start_time=task.constants.earliest_ip_start_time,
286
+ instrument=task.constants.instrument,
287
+ )
288
+
289
+ yield task
290
+ task._purge()
291
+
292
+
293
+ def test_visp_prepare_fit_data(
294
+ visp_prep_fit_data_task,
295
+ single_visp_instrument_options_list,
296
+ small_calibration_sequence,
297
+ small_beam_border,
298
+ mocker,
299
+ fake_gql_client,
300
+ ):
301
+ """
302
+ Given: A `VispPrepareFitData` task and INPUT and INTERMEDIATE frames needed for the task to run
303
+ When: Running the task
304
+ Then: The dang thing runs and produces the outputs we expect
305
+
306
+ NOTE: This is just a test of "glue" of the subclass. See other tests for checks that the visp-specific methods function
307
+ correctly.
308
+ """
309
+ mocker.patch(
310
+ "dkist_processing_common.tasks.mixin.metadata_store.GraphQLClient", new=fake_gql_client
311
+ )
312
+ shape = (small_beam_border * 2, small_beam_border)
313
+ inst_opt = single_visp_instrument_options_list[0]
314
+ task = visp_prep_fit_data_task
315
+
316
+ pol_status, pol_theta, ret_status, ret_theta, dark_status = small_calibration_sequence
317
+
318
+ write_calibration_sequence_frames_to_task(
319
+ task=task,
320
+ instrument=InstrumentName.visp,
321
+ arm_id=3,
322
+ wavelength=360.2,
323
+ pol_status=pol_status,
324
+ pol_theta=pol_theta,
325
+ ret_status=ret_status,
326
+ ret_theta=ret_theta,
327
+ dark_status=dark_status,
328
+ array_shape=shape,
329
+ tags=[TrendTag.arm_id(3)],
330
+ )
331
+
332
+ # INTERMEIDATE dark and gain frames
333
+ task.write(
334
+ data=np.full(shape, 0.0),
335
+ tags=[
336
+ TrendTag.intermediate(),
337
+ TrendTag.frame(),
338
+ TrendTag.arm_id(3),
339
+ TrendTag.task_dark(),
340
+ ],
341
+ encoder=fits_array_encoder,
342
+ )
343
+ task.write(
344
+ data=np.full(shape, 1.0),
345
+ tags=[
346
+ TrendTag.intermediate(),
347
+ TrendTag.frame(),
348
+ TrendTag.arm_id(3),
349
+ TrendTag.task_gain(),
350
+ ],
351
+ encoder=fits_array_encoder,
352
+ )
353
+
354
+ task()
355
+
356
+ for beam in range(1, 2):
357
+ global_tags = [
358
+ TrendTag.intermediate(),
359
+ TrendTag.arm_id(3),
360
+ TrendTag.beam(beam),
361
+ TrendTag.instrument_processing_options(inst_opt["name"]),
362
+ TrendTag.task_global_pac_input(),
363
+ ]
364
+ local_tags = [
365
+ TrendTag.intermediate(),
366
+ TrendTag.arm_id(3),
367
+ TrendTag.beam(beam),
368
+ TrendTag.instrument_processing_options(inst_opt["name"]),
369
+ TrendTag.task_local_pac_input(),
370
+ ]
371
+ assert task.count(global_tags) == 1
372
+ assert task.count(local_tags) == 1
373
+
374
+
375
+ def test_apply_visp_global_instrument_options(
376
+ visp_prep_fit_data_task, single_visp_instrument_options_list
377
+ ):
378
+ """
379
+ Given: A `VispPrepareFitData` task and an array
380
+ When: Converting the array into a "global" data point
381
+ Then: The correct output is returned
382
+ """
383
+ task = visp_prep_fit_data_task
384
+ inst_opt_object = VispInstrumentOptions.model_validate(single_visp_instrument_options_list[0])
385
+ array = np.full((100, 200), 100.0)
386
+ expected_median = np.median(array)
387
+
388
+ # Add some hairlines as sharp features in the spectral (0th) dimension
389
+ array[30, :] = 9999.9
390
+ array[70, :] = 121212.1
391
+
392
+ # And NaN's just b/c
393
+ array[34, 102] = np.nan
394
+
395
+ global_data = task.apply_global_instrument_options(
396
+ array=array, instrument_options=inst_opt_object
397
+ )
398
+ assert global_data.shape == (1, 1)
399
+ np.testing.assert_array_equal(global_data, expected_median)
400
+
401
+
402
+ def test_apply_visp_local_instrument_options(
403
+ visp_prep_fit_data_task, single_visp_instrument_options_list
404
+ ):
405
+ """
406
+ Given: A `VispPrepareFitData` task and an array
407
+ When: Converting the array into a "local" data point
408
+ Then: The correct output is returned
409
+ """
410
+ task = visp_prep_fit_data_task
411
+ inst_opt_object = VispInstrumentOptions.model_validate(single_visp_instrument_options_list[0])
412
+ array = np.full((100, 200), 100.0)
413
+ expected_median = np.median(array)
414
+
415
+ # Add high-frequency signal in the spectral dimension that will disappear with a median
416
+ # Signal of [-1, 0, 1, ...] will have a median of 0 => no change to overall median
417
+ array += (1 - np.arange(100) % 3)[:, None]
418
+
419
+ # Add some spikes in the spatial dimension that will get smoothed out
420
+ array[:, 130] *= 1.2
421
+ array[:, 180] *= 1.5
422
+
423
+ # Add some hairlines as sharp features in the spectral dimension
424
+ array[30, :] = 9999.9
425
+ array[70, :] = 121212.1
426
+
427
+ # And NaN's just b/c
428
+ array[34, 102] = np.nan
429
+
430
+ local_data = task.apply_local_instrument_options(
431
+ array=array, instrument_options=inst_opt_object
432
+ )
433
+ assert local_data.shape == (1, inst_opt_object.num_spatial_px)
434
+ np.testing.assert_array_equal(local_data, expected_median)
435
+
436
+
437
+ @pytest.fixture
438
+ def multiple_visp_instrument_options_list() -> list[dict]:
439
+ return [
440
+ {
441
+ "name": "visp_option1",
442
+ "num_spatial_px": 10,
443
+ "num_spectral_px": 1,
444
+ "spatial_smoothing_fit_order": 3,
445
+ },
446
+ {
447
+ "name": "visp_option2",
448
+ "num_spatial_px": 5,
449
+ "num_spectral_px": 1,
450
+ "spatial_smoothing_fit_order": 2,
451
+ },
452
+ ]
453
+
454
+
455
+ @pytest.fixture
456
+ def multiple_pac_fit_options_list() -> list[dict]:
457
+ return [
458
+ {
459
+ "name": "option1",
460
+ "remove_I_trend": True,
461
+ "fit_mode_name": "fit_mode1",
462
+ "init_set_name": "init_set1",
463
+ },
464
+ {
465
+ "name": "option2",
466
+ "remove_I_trend": False,
467
+ "fit_mode_name": "fit_mode1",
468
+ "init_set_name": "init_set2",
469
+ },
470
+ ]
471
+
472
+
473
+ @pytest.fixture
474
+ def visp_process_demod_task(
475
+ tmp_path,
476
+ recipe_run_id,
477
+ link_constants_db,
478
+ assign_input_dataset_doc_to_task,
479
+ small_beam_border,
480
+ multiple_visp_instrument_options_list,
481
+ multiple_pac_fit_options_list,
482
+ ):
483
+
484
+ link_constants_db(
485
+ recipe_run_id,
486
+ TrendTestingConstantsDatabase(
487
+ INSTRUMENT=InstrumentName.visp,
488
+ ARM_ID_LIST=[2],
489
+ ),
490
+ )
491
+ with VispProcessDemodulationMatrices(
492
+ arm_id=2, recipe_run_id=recipe_run_id, workflow_name="name", workflow_version="version"
493
+ ) as task:
494
+ task.scratch = WorkflowFileSystem(recipe_run_id=recipe_run_id, scratch_base_path=tmp_path)
495
+ assign_input_dataset_doc_to_task(
496
+ task,
497
+ TrendInputDatasetParameterValues(
498
+ trend_fit_options_list=multiple_pac_fit_options_list,
499
+ trend_instrument_processing_options_visp=multiple_visp_instrument_options_list,
500
+ trend_visp_beam_border=small_beam_border,
501
+ ),
502
+ )
503
+ task.parameters = TrendParameters(
504
+ scratch=task.scratch,
505
+ obs_ip_start_time=task.constants.earliest_ip_start_time,
506
+ instrument=task.constants.instrument,
507
+ )
508
+
509
+ yield task
510
+ task._purge()
511
+
512
+
513
+ def test_visp_process_demod_task(
514
+ visp_process_demod_task,
515
+ multiple_visp_instrument_options_list,
516
+ multiple_pac_fit_options_list,
517
+ small_beam_border,
518
+ mocker,
519
+ fake_gql_client,
520
+ ):
521
+ mocker.patch(
522
+ "dkist_processing_common.tasks.mixin.metadata_store.GraphQLClient", new=fake_gql_client
523
+ )
524
+ """
525
+ Given: A `VispProcessDemodulationMatrices` task and some best-fit demodulation matrices to mimic the output of running the PAC fitter
526
+ When: Running the task
527
+ Then: The task runs, the correct number of outputs is produced, and all processed demodulation matrices have the correct form
528
+ """
529
+
530
+ task = visp_process_demod_task
531
+
532
+ full_beam_shape = (small_beam_border, 102)
533
+ full_frame_shape = (full_beam_shape[0] * 2, full_beam_shape[1])
534
+ num_mod = 3
535
+ task.write(
536
+ data=np.empty(full_frame_shape),
537
+ tags=[
538
+ TrendTag.intermediate(),
539
+ TrendTag.frame(),
540
+ TrendTag.arm_id(task.arm_id),
541
+ TrendTag.task_gain(),
542
+ ],
543
+ encoder=fits_array_encoder,
544
+ )
545
+
546
+ for fit_options in multiple_pac_fit_options_list:
547
+ for instrument_options in multiple_visp_instrument_options_list:
548
+ num_spat = instrument_options["num_spatial_px"]
549
+ num_wave = instrument_options["num_spectral_px"]
550
+ for beam in [1, 2]:
551
+ # Add a linear signal so there's *something* for the fit to grab onto
552
+ # We divide by the linear signal instead of multiply because this matrix will be inverted for the actual fit.
553
+ raw_demod = (
554
+ np.ones((num_wave, num_spat, 4, num_mod))
555
+ / (np.arange(1, num_spat + 1) * 1000)[None, :, None, None]
556
+ )
557
+
558
+ task.write(
559
+ data=raw_demod,
560
+ tags=[
561
+ TrendTag.intermediate(),
562
+ TrendTag.arm_id(task.arm_id),
563
+ TrendTag.beam(beam),
564
+ TrendTag.pac_fit_options(fit_options["name"]),
565
+ TrendTag.instrument_processing_options(instrument_options["name"]),
566
+ TrendTag.task_best_fit_demodulation_matrices(),
567
+ ],
568
+ encoder=fits_array_encoder,
569
+ )
570
+
571
+ task()
572
+
573
+ for fit_options in multiple_pac_fit_options_list:
574
+ for instrument_options in multiple_visp_instrument_options_list:
575
+ for beam in [1, 2]:
576
+ tags = [
577
+ TrendTag.intermediate(),
578
+ TrendTag.arm_id(task.arm_id),
579
+ TrendTag.beam(beam),
580
+ TrendTag.pac_fit_options(fit_options["name"]),
581
+ TrendTag.instrument_processing_options(instrument_options["name"]),
582
+ TrendTag.task_processed_demodulation_matrices(),
583
+ ]
584
+ assert task.count(tags) == 1
585
+ processed_demod = next(task.read(tags=tags, decoder=fits_array_decoder))
586
+ assert processed_demod.shape == full_beam_shape + (4, num_mod)
587
+ # Test that the upsampling in the spectral dimension didn't do anything weird
588
+ np.testing.assert_almost_equal(np.std(processed_demod, axis=0), 0.0)
589
+
590
+
591
+ def test_smooth_demod_matrices(visp_process_demod_task):
592
+ """
593
+ Given: A demodulation matrix with known form in the spatial direction
594
+ When: Smoothing that demodulation matri
595
+ Then: The result has the right shape and the correct polynomial form
596
+ """
597
+ task = visp_process_demod_task
598
+ order = 5
599
+ coeffs = np.linspace(1, 37, order + 1)
600
+ full_num_spat = 200
601
+ binned_num_spat = 50
602
+ full_signal = np.poly1d(coeffs)(np.arange(full_num_spat))
603
+ idx = np.linspace(0, full_num_spat, binned_num_spat, endpoint=False, dtype=int)
604
+ binned_signal = full_signal[idx]
605
+
606
+ # Divide by the signal because we fit the polynomial in the inverse
607
+ binned_demod = 1.0 / binned_signal[None, :, None, None]
608
+
609
+ smoothed_demod = task.smooth_demod_matrices(binned_demod, order, full_num_spat)
610
+
611
+ assert smoothed_demod.shape == (1, full_num_spat, 1, 1)
612
+
613
+ # Like above, fit the inverse of the signal
614
+ fit_coeffs = np.polyfit(np.arange(full_num_spat), 1.0 / smoothed_demod[0, :, 0, 0], order)
615
+ np.testing.assert_allclose(coeffs, fit_coeffs, rtol=1e-5)
616
+
617
+
618
+ @pytest.fixture
619
+ def visp_dmpd_task(
620
+ tmp_path,
621
+ recipe_run_id,
622
+ link_constants_db,
623
+ assign_input_dataset_doc_to_task,
624
+ small_beam_border,
625
+ multiple_visp_instrument_options_list,
626
+ multiple_pac_fit_options_list,
627
+ ):
628
+
629
+ link_constants_db(
630
+ recipe_run_id,
631
+ TrendTestingConstantsDatabase(
632
+ INSTRUMENT=InstrumentName.visp,
633
+ ARM_ID_LIST=[2],
634
+ NUM_MODSTATES=3,
635
+ ),
636
+ )
637
+ with VispDemodulatedPolcalData(
638
+ arm_id=2, recipe_run_id=recipe_run_id, workflow_name="name", workflow_version="version"
639
+ ) as task:
640
+ task.scratch = WorkflowFileSystem(recipe_run_id=recipe_run_id, scratch_base_path=tmp_path)
641
+ assign_input_dataset_doc_to_task(
642
+ task,
643
+ TrendInputDatasetParameterValues(
644
+ trend_fit_options_list=multiple_pac_fit_options_list,
645
+ trend_instrument_processing_options_visp=multiple_visp_instrument_options_list,
646
+ trend_visp_beam_border=small_beam_border,
647
+ ),
648
+ )
649
+ task.parameters = TrendParameters(
650
+ scratch=task.scratch,
651
+ obs_ip_start_time=task.constants.earliest_ip_start_time,
652
+ instrument=task.constants.instrument,
653
+ )
654
+
655
+ yield task
656
+ task._purge()
657
+
658
+
659
+ def test_visp_dmpd_task(
660
+ visp_dmpd_task,
661
+ small_calibration_sequence,
662
+ small_num_cs_steps,
663
+ small_beam_border,
664
+ multiple_pac_fit_options_list,
665
+ multiple_visp_instrument_options_list,
666
+ mocker,
667
+ fake_gql_client,
668
+ ):
669
+ mocker.patch(
670
+ "dkist_processing_common.tasks.mixin.metadata_store.GraphQLClient", new=fake_gql_client
671
+ )
672
+ shape = (small_beam_border * 2, small_beam_border)
673
+ task = visp_dmpd_task
674
+
675
+ pol_status, pol_theta, ret_status, ret_theta, dark_status = small_calibration_sequence
676
+ num_mod = task.constants.num_modstates
677
+ write_calibration_sequence_frames_to_task(
678
+ task=task,
679
+ instrument=InstrumentName.visp,
680
+ num_modstates=num_mod,
681
+ arm_id=2,
682
+ wavelength=360.2,
683
+ pol_status=pol_status,
684
+ pol_theta=pol_theta,
685
+ ret_status=ret_status,
686
+ ret_theta=ret_theta,
687
+ dark_status=dark_status,
688
+ array_shape=shape,
689
+ tags=[TrendTag.input(), TrendTag.arm_id(2)],
690
+ )
691
+
692
+ # INTERMEIDATE dark, gain, geo, and processed demod frames
693
+ task.write(
694
+ data=np.full(shape, 0.0),
695
+ tags=[
696
+ TrendTag.intermediate(),
697
+ TrendTag.frame(),
698
+ TrendTag.arm_id(2),
699
+ TrendTag.task_dark(),
700
+ ],
701
+ encoder=fits_array_encoder,
702
+ )
703
+ task.write(
704
+ data=np.full(shape, 1.0),
705
+ tags=[
706
+ TrendTag.intermediate(),
707
+ TrendTag.frame(),
708
+ TrendTag.arm_id(2),
709
+ TrendTag.task_gain(),
710
+ ],
711
+ encoder=fits_array_encoder,
712
+ )
713
+ task.write(
714
+ data={"angle_rad": 0.004, "shift": np.array([0.0, 0.0])},
715
+ tags=[
716
+ TrendTag.intermediate(),
717
+ TrendTag.arm_id(2),
718
+ TrendTag.beam(1),
719
+ TrendTag.task_visp_geometric_calibration(),
720
+ ],
721
+ encoder=asdf_encoder,
722
+ )
723
+ task.write(
724
+ data={"angle_rad": -0.004, "shift": np.array([5.0, 12.3])},
725
+ tags=[
726
+ TrendTag.intermediate(),
727
+ TrendTag.arm_id(2),
728
+ TrendTag.beam(2),
729
+ TrendTag.task_visp_geometric_calibration(),
730
+ ],
731
+ encoder=asdf_encoder,
732
+ )
733
+ demod = np.random.random((shape[0] // 2, shape[1], 4, num_mod))
734
+ for fit_options in multiple_pac_fit_options_list:
735
+ for inst_options in multiple_visp_instrument_options_list:
736
+ task.write(
737
+ data=demod,
738
+ tags=[
739
+ TrendTag.intermediate(),
740
+ TrendTag.arm_id(2),
741
+ TrendTag.pac_fit_options(fit_options["name"]),
742
+ TrendTag.instrument_processing_options(inst_options["name"]),
743
+ TrendTag.beam(1),
744
+ TrendTag.task_processed_demodulation_matrices(),
745
+ ],
746
+ encoder=fits_array_encoder,
747
+ )
748
+ task.write(
749
+ data=demod,
750
+ tags=[
751
+ TrendTag.intermediate(),
752
+ TrendTag.arm_id(2),
753
+ TrendTag.pac_fit_options(fit_options["name"]),
754
+ TrendTag.instrument_processing_options(inst_options["name"]),
755
+ TrendTag.beam(2),
756
+ TrendTag.task_processed_demodulation_matrices(),
757
+ ],
758
+ encoder=fits_array_encoder,
759
+ )
760
+
761
+ task()
762
+
763
+ # -5 and -13 come from the shift values written in the geo intermediate above
764
+ expected_beam_shape = (shape[0] // 2 - 5, shape[1] - 13)
765
+ expected_full_shape = (1, small_num_cs_steps, 4, *expected_beam_shape)
766
+ for fit_options in multiple_pac_fit_options_list:
767
+ for inst_options in multiple_visp_instrument_options_list:
768
+ tags = [
769
+ TrendTag.intermediate(),
770
+ TrendTag.arm_id(2),
771
+ TrendTag.pac_fit_options(fit_options["name"]),
772
+ TrendTag.instrument_processing_options(inst_options["name"]),
773
+ ]
774
+ beam1_tags = tags + [
775
+ TrendTag.beam(1),
776
+ TrendTag.task_single_beam_demodulated_polcal_data(),
777
+ ]
778
+ beam2_tags = tags + [
779
+ TrendTag.beam(2),
780
+ TrendTag.task_single_beam_demodulated_polcal_data(),
781
+ ]
782
+ combined_tags = tags + [TrendTag.task_demodulated_polcal_data()]
783
+
784
+ assert task.count(tags=beam1_tags) == 1
785
+ assert task.count(tags=beam2_tags) == 1
786
+ assert task.count(tags=combined_tags) == 1
787
+
788
+ beam1_dmdp = next(task.read(tags=beam1_tags, decoder=fits_array_decoder))
789
+ beam2_dmdp = next(task.read(tags=beam2_tags, decoder=fits_array_decoder))
790
+ combined_dmdp = next(task.read(tags=combined_tags, decoder=fits_array_decoder))
791
+
792
+ assert beam1_dmdp.shape == expected_full_shape
793
+ # Testing >f4 instead of np.float32 b/c PrimaryHDUs change endianess
794
+ assert beam1_dmdp.dtype == np.dtype(">f4")
795
+ assert beam2_dmdp.shape == expected_full_shape
796
+ assert beam2_dmdp.dtype == np.dtype(">f4")
797
+ assert combined_dmdp.shape == expected_full_shape
798
+ assert combined_dmdp.dtype == np.dtype(">f4")
799
+
800
+
801
+ @pytest.mark.parametrize(
802
+ "shifts, expected",
803
+ # Shifts have shape (num_beams, 2)
804
+ # So the inner-most lists below (e.g., [5.0, 6.0]) correspond to [x_shift, y_shit]
805
+ [
806
+ (
807
+ np.array(
808
+ [
809
+ [0.0, 0.0], # Beam 1
810
+ [11.0, 10.0], # Beam 2
811
+ ]
812
+ ),
813
+ [slice(0, -11, None), slice(0, -10, None)],
814
+ ),
815
+ (
816
+ np.array(
817
+ [
818
+ [0.0, 0.0], # Beam 1
819
+ [-11.0, -10.0], # Beam 2
820
+ ]
821
+ ),
822
+ [slice(11, None, None), slice(10, None, None)],
823
+ ),
824
+ (
825
+ np.array(
826
+ [ # This case would never happen, but is useful as a check of the logic
827
+ [10.0, -2.0], # Beam 1
828
+ [-11.0, 10.0], # Beam 2
829
+ ]
830
+ ),
831
+ [slice(11, -10, None), slice(2, -10, None)],
832
+ ),
833
+ ],
834
+ ids=["All positive", "All negative", "Positive and negative"],
835
+ )
836
+ def test_beam_overlap_slice(shifts, expected):
837
+ """
838
+ Given: A `VispCalibrationCollection` object with populated shifts
839
+ When: Computing the overlapping beam slices
840
+ Then: The correct values are returned
841
+ """
842
+ shift_dict = {i: v for i, v in enumerate(shifts)}
843
+ calibrations = VispCalibrationCollection(
844
+ dark=np.empty(2), gain=np.empty(2), angle=dict(), shift=shift_dict, demod_matrices=dict()
845
+ )
846
+ x_slice, y_slice = calibrations.beams_overlap_slice
847
+
848
+ assert x_slice == expected[0]
849
+ assert y_slice == expected[1]
850
+
851
+
852
+ def test_combine_beams(visp_dmpd_task):
853
+ """
854
+ Given: A `VispDemodulatedPolcalData` task and set of calibrated array data
855
+ When: Combining the two beams
856
+ Then: The correct result is returned
857
+ """
858
+ task = visp_dmpd_task
859
+ shape = (10, 10, 4)
860
+ beam1 = np.ones(shape) + np.arange(4)[None, None, :]
861
+ beam2 = np.ones(shape) + np.arange(4)[::-1][None, None, :]
862
+ result = task.combine_beams(
863
+ beam1_data=beam1,
864
+ beam2_data=beam2,
865
+ )
866
+
867
+ x = np.arange(1, 5)
868
+ expected_I = np.ones((10, 10)) * 2.5
869
+ expected_Q = np.ones((10, 10)) * (x[1] / x[0] + x[-2] / x[-1]) / 2.0 * 2.5
870
+ expected_U = np.ones((10, 10)) * (x[2] / x[0] + x[-3] / x[-1]) / 2.0 * 2.5
871
+ expected_V = np.ones((10, 10)) * (x[3] / x[0] + x[-4] / x[-1]) / 2.0 * 2.5
872
+ expected = np.dstack([expected_I, expected_Q, expected_U, expected_V])
873
+
874
+ np.testing.assert_array_equal(result, expected)