dkist-processing-dlnirsp 0.32.8__py3-none-any.whl → 0.33.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 (32) hide show
  1. dkist_processing_dlnirsp/models/constants.py +6 -0
  2. dkist_processing_dlnirsp/models/parameters.py +33 -3
  3. dkist_processing_dlnirsp/parsers/task.py +2 -25
  4. dkist_processing_dlnirsp/parsers/time.py +2 -2
  5. dkist_processing_dlnirsp/tasks/__init__.py +1 -2
  6. dkist_processing_dlnirsp/tasks/movie.py +1121 -0
  7. dkist_processing_dlnirsp/tasks/parse.py +13 -8
  8. dkist_processing_dlnirsp/tasks/solar.py +129 -30
  9. dkist_processing_dlnirsp/tests/conftest.py +46 -7
  10. dkist_processing_dlnirsp/tests/local_trial_workflows/l0_polcals_as_science.py +21 -18
  11. dkist_processing_dlnirsp/tests/local_trial_workflows/l0_solar_gain_as_science.py +21 -18
  12. dkist_processing_dlnirsp/tests/local_trial_workflows/l0_to_l1.py +21 -18
  13. dkist_processing_dlnirsp/tests/local_trial_workflows/local_trial_dev_mockers.py +1 -1
  14. dkist_processing_dlnirsp/tests/test_dlnirsp_constants.py +2 -0
  15. dkist_processing_dlnirsp/tests/test_movie.py +141 -0
  16. dkist_processing_dlnirsp/tests/test_parameters.py +8 -0
  17. dkist_processing_dlnirsp/tests/test_parse.py +10 -0
  18. dkist_processing_dlnirsp/tests/test_science.py +0 -9
  19. dkist_processing_dlnirsp/tests/test_solar.py +114 -17
  20. dkist_processing_dlnirsp/tests/test_wavelength_calibration.py +4 -1
  21. dkist_processing_dlnirsp/workflows/l0_processing.py +6 -8
  22. dkist_processing_dlnirsp/workflows/trial_workflow.py +7 -7
  23. {dkist_processing_dlnirsp-0.32.8.dist-info → dkist_processing_dlnirsp-0.33.0.dist-info}/METADATA +52 -35
  24. {dkist_processing_dlnirsp-0.32.8.dist-info → dkist_processing_dlnirsp-0.33.0.dist-info}/RECORD +28 -30
  25. docs/gain.rst +7 -3
  26. dkist_processing_dlnirsp/tasks/assemble_movie.py +0 -150
  27. dkist_processing_dlnirsp/tasks/make_movie_frames.py +0 -156
  28. dkist_processing_dlnirsp/tests/test_assemble_movie.py +0 -169
  29. dkist_processing_dlnirsp/tests/test_make_movie_frames.py +0 -98
  30. {dkist_processing_dlnirsp-0.32.8.dist-info → dkist_processing_dlnirsp-0.33.0.dist-info}/WHEEL +0 -0
  31. {dkist_processing_dlnirsp-0.32.8.dist-info → dkist_processing_dlnirsp-0.33.0.dist-info}/entry_points.txt +0 -0
  32. {dkist_processing_dlnirsp-0.32.8.dist-info → dkist_processing_dlnirsp-0.33.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1121 @@
1
+ """Module for making highly-custom DL-NIRSP movies."""
2
+
3
+ import logging
4
+ import warnings
5
+ from datetime import datetime
6
+ from datetime import timedelta
7
+ from functools import cached_property
8
+ from functools import partial
9
+ from itertools import repeat
10
+ from logging import getLogger
11
+ from pathlib import Path
12
+
13
+ import astropy.units as u
14
+ import imageio_ffmpeg
15
+ import numpy as np
16
+ from astropy.convolution import interpolate_replace_nans
17
+ from astropy.coordinates import SkyCoord
18
+ from astropy.io import fits
19
+ from astropy.time import Time
20
+ from astropy.utils.exceptions import AstropyWarning
21
+ from astropy.visualization.wcsaxes import WCSAxes
22
+ from astropy.wcs import WCS
23
+ from astropy.wcs.utils import wcs_to_celestial_frame
24
+ from dkist_processing_common.codecs.fits import fits_hdu_decoder
25
+ from dkist_processing_common.codecs.json import json_decoder
26
+ from dkist_processing_common.models.dkist_location import location_of_dkist
27
+ from dkist_processing_common.tasks import WriteL1Frame
28
+ from dkist_service_configuration.logging import logger
29
+ from dkist_spectral_lines import get_closest_spectral_line
30
+ from matplotlib import animation
31
+ from matplotlib import pyplot as plt
32
+ from matplotlib import rcdefaults
33
+ from matplotlib import rcParams
34
+ from matplotlib.axes import Axes
35
+ from matplotlib.patches import Polygon
36
+ from matplotlib.text import Text
37
+ from reproject import reproject_interp
38
+ from reproject.mosaicking import find_optimal_celestial_wcs
39
+ from reproject.mosaicking import reproject_and_coadd
40
+ from sunpy.coordinates import HeliocentricInertial
41
+ from sunpy.coordinates import Helioprojective
42
+ from sunpy.coordinates import SphericalScreen
43
+
44
+ from dkist_processing_dlnirsp.models.tags import DlnirspTag
45
+ from dkist_processing_dlnirsp.parsers.dlnirsp_l0_fits_access import DlnirspL0FitsAccess
46
+ from dkist_processing_dlnirsp.tasks.dlnirsp_base import DlnirspTaskBase
47
+
48
+ __all__ = ["MakeDlnirspMovie"]
49
+
50
+ reproject_logger = getLogger("reproject.mosaicking.coadd")
51
+ reproject_logger.setLevel(logging.WARNING)
52
+ warnings.simplefilter("ignore", category=AstropyWarning)
53
+ warnings.simplefilter("ignore", category=RuntimeWarning)
54
+
55
+
56
+ class MakeDlnirspMovie(DlnirspTaskBase):
57
+ """
58
+ Task class for DL-NIRSP quicklook movie generation from CALIBRATED frames.
59
+
60
+ Parameters
61
+ ----------
62
+ recipe_run_id : int
63
+ id of the recipe run used to identify the workflow run this task is part of
64
+ workflow_name : str
65
+ name of the workflow to which this instance of the task belongs
66
+ workflow_version : str
67
+ version of the workflow to which this instance of the task belongs
68
+ """
69
+
70
+ def setup_look_and_feel(self) -> None:
71
+ """Initialize figure and movie parameters that affect the overall look of the movie."""
72
+ rcdefaults()
73
+
74
+ # Use the cross-platform ffmpeg binary provided by `imageio_ffmpeg`
75
+ rcParams["animation.ffmpeg_path"] = imageio_ffmpeg.get_ffmpeg_exe()
76
+ logger.info(f"mpl ffmpeg path set to {rcParams['animation.ffmpeg_path']}")
77
+
78
+ width_pixels = 1920
79
+ height_pixels = 1080
80
+ self.dpi = 300
81
+ self.fig_width_inches = width_pixels / self.dpi
82
+ self.fig_height_inches = height_pixels / self.dpi
83
+
84
+ # Define the location of the mosaic axes
85
+ self.mosaic_left = 0.3
86
+ self.mosaic_bottom = 0.06
87
+ self.mosaic_width = 0.33
88
+ self.mosaic_height = 0.36
89
+ self.mosaic_h_padding = 0.02
90
+ self.mosaic_v_padding = 0.05
91
+
92
+ # Fraction of full axis size to pad limits
93
+ self.fractional_mosaic_axis_limit_pad = 0.01
94
+
95
+ self.spec_left = 0.02
96
+ self.spec_bottom = 0.06
97
+ self.spec_width = 0.1
98
+ self.spec_height = 0.11
99
+ self.spec_h_padding = 0.01
100
+ self.spec_v_padding = 0.03
101
+ self.stack_height = 0.65
102
+
103
+ self.arrow_x = 0.92
104
+ self.arrow_y = 0.88
105
+ self.arrow_length = 0.08
106
+
107
+ axis_frame_linewidth = 0.5
108
+
109
+ maximum_duration_ms = 3 * 60.0 * 1000
110
+ maximum_interval_ms = 300.0
111
+
112
+ # We don't care about dithers here, because they will be stitched into the same mosaic
113
+ self.num_movie_frames = (
114
+ self.constants.num_mosaic_repeats
115
+ * self.constants.num_mosaic_tiles_x
116
+ * self.constants.num_mosaic_tiles_y
117
+ )
118
+
119
+ # Ensures we won't be longer than the max duration or slower than the maximum interval
120
+ duration_ms = min(maximum_duration_ms, self.num_movie_frames * maximum_interval_ms)
121
+ self.frame_interval = duration_ms / self.num_movie_frames
122
+ logger.info(
123
+ f"With {self.num_movie_frames} frames the frame interval will be {self.frame_interval} ms"
124
+ )
125
+
126
+ plt.rcParams.update(
127
+ {
128
+ "figure.dpi": self.dpi,
129
+ "font.size": 9, # Default font size for all text
130
+ "font.family": "DejaVu Sans",
131
+ "image.origin": "lower",
132
+ "image.cmap": "gray",
133
+ "image.interpolation": "nearest",
134
+ "axes.labelsize": 9, # Font size for x and y labels
135
+ "xtick.labelsize": 5, # Font size for x-axis ticks
136
+ "ytick.labelsize": 5, # Font size for y-axis ticks
137
+ "lines.linewidth": 0.7,
138
+ "axes.linewidth": axis_frame_linewidth,
139
+ "xtick.major.width": axis_frame_linewidth,
140
+ "xtick.minor.width": axis_frame_linewidth,
141
+ "ytick.major.width": axis_frame_linewidth,
142
+ "ytick.minor.width": axis_frame_linewidth,
143
+ }
144
+ )
145
+
146
+ def run(self):
147
+ """
148
+ Build a movie showing mosaic images, stacked spectra, and single spectral plots.
149
+
150
+ Each mosaic step is a single "frame" in the movie. If the data are polarimetric then a core and continuum
151
+ image/spectrum are shown for both Stokes I and V. For intensity mode data the Stokes V images are replaced with
152
+ text stating the dataset is intensity mode.
153
+
154
+ The steps of this method are:
155
+
156
+ #. Setup/compute movie metadata (spectral line, wavelength indices for core and continuum, etc.)
157
+ #. Read all Stokes I and (if available) Stokes V CALIBRATED frames and order by DATE-BEG
158
+ #. Using all Stokes I frames, compute a reference WCS that will be the canvas for all mosaic images
159
+ #. Set up the plotting canvas. This involves places axes, filling them with data from the first step, and setting instance variables that will be updated for each movie frame
160
+ #. Animate the plot for each scan step and save the resultant movie file
161
+ """
162
+ with self.telemetry_span("Set up movie variables"):
163
+ self.setup_look_and_feel()
164
+ obs_part_id = self.metadata_store_input_dataset_observe_frames.inputDatasetPartId
165
+ self.product_id = WriteL1Frame.compute_product_id(obs_part_id, "L1")
166
+
167
+ any_header = self.add_L1_header_values(self.get_any_calibrated_header())
168
+ single_step_spatial_shape = (any_header["NAXIS2"], any_header["NAXIS1"])
169
+ self.num_wave = any_header["NAXIS3"]
170
+ self.num_spec = np.prod(single_step_spatial_shape)
171
+ logger.info(
172
+ f"Each step has spatial shape {single_step_spatial_shape} => {self.num_spec} total spectra"
173
+ )
174
+ logger.info(f"There are {self.num_wave} wavelength pixels")
175
+
176
+ self.wave_abscissa = self.compute_wavelength_abscissa(any_header)
177
+ self.core_wave_idx = np.argmin(
178
+ np.abs(self.wave_abscissa - self.parameters.movie_core_wave_value_nm)
179
+ )
180
+ self.cont_wave_idx = np.argmin(
181
+ np.abs(self.wave_abscissa - self.parameters.movie_cont_wave_value_nm)
182
+ )
183
+ central_wave = np.nanmean(self.wave_abscissa)
184
+ self.spectral_line_name = get_closest_spectral_line(central_wave * u.nm).name
185
+ logger.info(
186
+ f"With center wavelength {central_wave:.3f} nm the spectral line is {self.spectral_line_name}"
187
+ )
188
+
189
+ with self.telemetry_span("Read CALIBRATED files"):
190
+ logger.info("Reading CALIBRATED files")
191
+ self.I_file_list = sorted(
192
+ self.read(
193
+ tags=[DlnirspTag.calibrated(), DlnirspTag.frame(), DlnirspTag.stokes("I")]
194
+ ),
195
+ key=lambda fo: fits.getheader(fo)["DATE-OBS"],
196
+ )
197
+ self.V_file_list = sorted(
198
+ self.read(
199
+ tags=[DlnirspTag.calibrated(), DlnirspTag.frame(), DlnirspTag.stokes("V")]
200
+ ),
201
+ key=lambda fo: fits.getheader(fo)["DATE-OBS"],
202
+ )
203
+
204
+ with self.telemetry_span("Compute reference WCS"):
205
+ self.reference_wcs, self.full_mosaic_shape = self.compute_reference_wcs(
206
+ self.I_file_list, single_step_spatial_shape
207
+ )
208
+ self.reference_coord_frame = wcs_to_celestial_frame(self.reference_wcs)
209
+ reference_observer = self.reference_coord_frame.observer
210
+ self.spherical_screen = SphericalScreen(center=reference_observer, only_off_disk=True)
211
+
212
+ with self.telemetry_span("Initialize plot"):
213
+ logger.info("Setting up plot")
214
+ self.currently_shown_mosaic = 0
215
+ self.fig = self.init_plot()
216
+
217
+ with self.telemetry_span("Render movie"):
218
+ logger.info("Rendering movie")
219
+ relative_movie_path = f"{self.constants.dataset_id}_browse_movie.mp4"
220
+ absolute_movie_path = str(self.scratch.absolute_path(relative_movie_path))
221
+ update_func = partial(
222
+ update_plot, task_obj=self, is_polarimetric=self.constants.correct_for_polarization
223
+ )
224
+ ani = animation.FuncAnimation(
225
+ fig=self.fig,
226
+ func=update_func,
227
+ frames=self.num_movie_frames,
228
+ interval=self.frame_interval,
229
+ )
230
+ ani.save(filename=absolute_movie_path, writer="ffmpeg", dpi=self.dpi)
231
+ self.tag(path=absolute_movie_path, tags=[DlnirspTag.output(), DlnirspTag.movie()])
232
+
233
+ @cached_property
234
+ def wavelength_solution(self) -> dict[str, str | int | float]:
235
+ """Load the wavelength solution from disk."""
236
+ return next(
237
+ self.read(
238
+ tags=[DlnirspTag.intermediate(), DlnirspTag.task_wavelength_solution()],
239
+ decoder=json_decoder,
240
+ )
241
+ )
242
+
243
+ def get_any_calibrated_header(self) -> fits.Header:
244
+ """Return *any* calibrated header."""
245
+ tags = [DlnirspTag.calibrated(), DlnirspTag.frame()]
246
+ return next(self.read(tags=tags, decoder=fits_hdu_decoder)).header
247
+
248
+ def compute_wavelength_abscissa(self, header: fits.Header) -> np.ndarray:
249
+ """
250
+ Compute a wavelength vector from the WCS in a header.
251
+
252
+ The header passed in should have a spectral WCS axis.
253
+ """
254
+ wcs = WCS(header)
255
+ abscissa = wcs.spectral.pixel_to_world(np.arange(self.num_wave)).to_value(u.nm)
256
+ return abscissa
257
+
258
+ def compute_reference_wcs(
259
+ self, file_list: list[Path], spatial_shape: tuple[int, int]
260
+ ) -> tuple[WCS, tuple[int, int]]:
261
+ """Examine the spatial extents of *all* calibrated files and compute an optimal WCS region that covers all mosaics."""
262
+ logger.info("Parsing headers")
263
+ wcs_list = []
264
+ for f in file_list:
265
+ hdul = fits.open(f)
266
+ header = self.add_L1_header_values(hdul[0].header)
267
+ wcs_list.append(WCS(header).dropaxis(2))
268
+ del hdul
269
+
270
+ logger.info("Computing reference WCS")
271
+ reference_wcs, shape_out = find_optimal_celestial_wcs(
272
+ tuple(zip(repeat(spatial_shape), wcs_list)),
273
+ auto_rotate=False,
274
+ negative_lon_cdelt="auto",
275
+ )
276
+
277
+ return reference_wcs, shape_out
278
+
279
+ def init_plot(self):
280
+ """
281
+ Initialize the full plotting canvas and fill with data from the first mosaic step.
282
+
283
+ This function lays out the axes for each individual plot and defines instance properties for the parts of the
284
+ figure that will change when the plot is updated. This properties can then be referenced and modified in
285
+ `update_plot`.
286
+ """
287
+ logger.info("Computing initial mosaics")
288
+ I_mosaic_data, I_mosaic_WCS, I_mosaic_dates = self.read_mosaic_frames(
289
+ mosaic_num=0, stokes="I"
290
+ )
291
+ I_mosaic_list = self.build_mosaic_images(data_list=I_mosaic_data, wcs_list=I_mosaic_WCS)
292
+
293
+ core_iscales = np.nanpercentile(I_mosaic_list[0], [5, 95]) + np.array([-0.1, 0.1])
294
+ cont_iscales = np.nanpercentile(I_mosaic_list[1], [5, 95]) + np.array([-0.1, 0.1])
295
+
296
+ if self.constants.correct_for_polarization:
297
+ V_mosaic_data, V_mosaic_WCS, _ = self.read_mosaic_frames(mosaic_num=0, stokes="V")
298
+ V_mosaic_list = self.build_mosaic_images(data_list=V_mosaic_data, wcs_list=V_mosaic_WCS)
299
+
300
+ V_core_ratio = V_mosaic_list[0] / I_mosaic_list[0]
301
+ V_cont_ratio = V_mosaic_list[1] / I_mosaic_list[1]
302
+ core_vscale = np.nanpercentile(np.abs(V_core_ratio), 95)
303
+ cont_vscale = np.nanpercentile(np.abs(V_cont_ratio), 95)
304
+ self.vscale = np.nanmax([core_vscale, cont_vscale])
305
+ else:
306
+ V_core_ratio = np.empty_like(I_mosaic_list[0]) * np.nan
307
+ V_cont_ratio = np.empty_like(I_mosaic_list[0]) * np.nan
308
+ core_vscale = 1.0
309
+ cont_vscale = 1.0
310
+ self.vscale = 1.0
311
+
312
+ fig = plt.figure(figsize=(self.fig_width_inches, self.fig_height_inches))
313
+
314
+ logger.info("Building mosaic plots")
315
+ self._init_mosaic_plots(
316
+ fig=fig,
317
+ I_core_mosaic=I_mosaic_list[0],
318
+ I_cont_mosaic=I_mosaic_list[1],
319
+ V_core_ratio=V_core_ratio,
320
+ V_cont_ratio=V_cont_ratio,
321
+ core_iscales=core_iscales,
322
+ cont_iscales=cont_iscales,
323
+ core_vscale=core_vscale,
324
+ cont_vscale=cont_vscale,
325
+ )
326
+
327
+ logger.info("Building stack plots")
328
+
329
+ I_stack = self.get_stacked_spectra(self.I_file_list[0])
330
+ if self.constants.correct_for_polarization:
331
+ V_stack = self.get_stacked_spectra(self.V_file_list[0])
332
+ V_ratio = V_stack / I_stack
333
+ else:
334
+ V_ratio = np.empty_like(I_stack) * np.nan
335
+
336
+ I_stack_axis = self._init_stack_plots(fig=fig, I_stack=I_stack, V_ratio=V_ratio)
337
+
338
+ logger.info("Building spectral plots")
339
+ self._init_spectral_plots(
340
+ fig=fig, I_stack_axis=I_stack_axis, I_stack=I_stack, V_ratio=V_ratio
341
+ )
342
+
343
+ fig.text(
344
+ 0.3,
345
+ 0.98,
346
+ f"DKIST/DLNIRSP Level 1 Quicklook -- Dataset {self.constants.dataset_id}",
347
+ ha="left",
348
+ fontsize=8,
349
+ color="green",
350
+ va="top",
351
+ fontweight="bold",
352
+ )
353
+ mosaic_start_time, mosaic_duration = self.compute_mosaic_timing(I_mosaic_dates)
354
+ self.mosaic_info_title = fig.text(
355
+ 0.3,
356
+ 0.95,
357
+ self.format_title_text(mosaic_start_time, mosaic_duration),
358
+ ha="left",
359
+ fontsize=5.0,
360
+ va="top",
361
+ )
362
+ first_header = fits.getheader(self.I_file_list[0])
363
+ first_date_beg = first_header["DATE-BEG"]
364
+ first_mindex1 = first_header["MINDEX1"]
365
+ first_mindex2 = first_header["MINDEX2"]
366
+ self.step_info_title = fig.text(
367
+ 0.25 / 2.0,
368
+ 0.98,
369
+ self.format_step_info_text(
370
+ date_beg=first_date_beg, mindex1=first_mindex1, mindex2=first_mindex2
371
+ ),
372
+ ha="center",
373
+ fontsize=6,
374
+ va="top",
375
+ color="k",
376
+ )
377
+ fig.text(
378
+ 0.25 / 2.0, 0.89, "Stacked IFU Spectra", ha="center", fontsize=6, color="tab:orange"
379
+ )
380
+ return fig
381
+
382
+ def _init_mosaic_plots(
383
+ self,
384
+ *,
385
+ fig: plt.Figure,
386
+ I_core_mosaic: np.ndarray,
387
+ I_cont_mosaic: np.ndarray,
388
+ V_core_ratio: np.ndarray,
389
+ V_cont_ratio: np.ndarray,
390
+ core_iscales: np.ndarray,
391
+ cont_iscales: np.ndarray,
392
+ core_vscale: float,
393
+ cont_vscale: float,
394
+ ):
395
+ """Initialize four plots of the full mosaic image: line core and continuum for Stokes I and V."""
396
+ I_mosaic_core_axis: WCSAxes = fig.add_axes(
397
+ (
398
+ self.mosaic_left,
399
+ self.mosaic_bottom + self.mosaic_height + self.mosaic_v_padding,
400
+ self.mosaic_width,
401
+ self.mosaic_height,
402
+ ),
403
+ projection=self.reference_wcs,
404
+ )
405
+ V_mosaic_core_axis: WCSAxes = fig.add_axes(
406
+ (
407
+ self.mosaic_left + self.mosaic_width + self.mosaic_h_padding,
408
+ self.mosaic_bottom + self.mosaic_height + self.mosaic_v_padding,
409
+ self.mosaic_width,
410
+ self.mosaic_height,
411
+ ),
412
+ projection=self.reference_wcs,
413
+ )
414
+ I_mosaic_cont_axis: WCSAxes = fig.add_axes(
415
+ (self.mosaic_left, self.mosaic_bottom, self.mosaic_width, self.mosaic_height),
416
+ projection=self.reference_wcs,
417
+ )
418
+ V_mosaic_cont_axis: WCSAxes = fig.add_axes(
419
+ (
420
+ self.mosaic_left + self.mosaic_width + self.mosaic_h_padding,
421
+ self.mosaic_bottom,
422
+ self.mosaic_width,
423
+ self.mosaic_height,
424
+ ),
425
+ projection=self.reference_wcs,
426
+ )
427
+
428
+ if not self.constants.correct_for_polarization:
429
+ for ax in [V_mosaic_core_axis, V_mosaic_cont_axis]:
430
+ ax.set_facecolor("paleturquoise")
431
+ ax.text(
432
+ 0.5,
433
+ 0.5,
434
+ "Intensity\nmode",
435
+ transform=ax.transAxes,
436
+ fontsize=7,
437
+ ha="center",
438
+ va="center",
439
+ color="k",
440
+ )
441
+
442
+ self.I_mosaic_core_im = I_mosaic_core_axis.imshow(
443
+ I_core_mosaic,
444
+ vmin=core_iscales[0],
445
+ vmax=core_iscales[1],
446
+ zorder=2,
447
+ )
448
+ self.I_mosaic_cont_im = I_mosaic_cont_axis.imshow(
449
+ I_cont_mosaic,
450
+ vmin=cont_iscales[0],
451
+ vmax=cont_iscales[1],
452
+ zorder=2,
453
+ )
454
+ self.V_mosaic_core_im = V_mosaic_core_axis.imshow(
455
+ V_core_ratio,
456
+ vmin=-core_vscale,
457
+ vmax=core_vscale,
458
+ zorder=2,
459
+ )
460
+ self.V_mosaic_cont_im = V_mosaic_cont_axis.imshow(
461
+ V_cont_ratio,
462
+ vmin=-cont_vscale,
463
+ vmax=cont_vscale,
464
+ zorder=2,
465
+ )
466
+
467
+ corners = self.compute_step_wcs_box(self.I_file_list[0])
468
+
469
+ self.I_core_patch = self._add_patch_to_axis(
470
+ ax=I_mosaic_core_axis, corners=corners, color="blue"
471
+ )
472
+ self.I_cont_patch = self._add_patch_to_axis(
473
+ ax=I_mosaic_cont_axis, corners=corners, color="orange"
474
+ )
475
+
476
+ self._add_ax_top_text(
477
+ ax=I_mosaic_core_axis,
478
+ text=rf"Stokes I [$\lambda = ${self.parameters.movie_core_wave_value_nm:.2f} nm]",
479
+ fontsize=5,
480
+ fontweight="bold",
481
+ )
482
+ self._add_ax_top_text(
483
+ ax=I_mosaic_cont_axis,
484
+ text=rf"Stokes I [$\lambda = ${self.parameters.movie_cont_wave_value_nm:.2f} nm]",
485
+ fontsize=5,
486
+ fontweight="bold",
487
+ )
488
+
489
+ for ax in [I_mosaic_core_axis, I_mosaic_cont_axis, V_mosaic_core_axis, V_mosaic_cont_axis]:
490
+ ax.set_ylabel("")
491
+ ax.set_xlabel("")
492
+ self.maximize_mosaic_axis_area(ax)
493
+ self.pad_mosaic_axis(ax)
494
+
495
+ if (
496
+ ax in [I_mosaic_core_axis, I_mosaic_cont_axis]
497
+ or self.constants.correct_for_polarization
498
+ ):
499
+ ax.coords.grid(color="grey", linestyle="dashed", alpha=0.7, linewidth=0.3, zorder=1)
500
+
501
+ self.clean_axis_spines(I_mosaic_core_axis, show_xtick_labels=False, show_ytick_labels=True)
502
+ self.clean_axis_spines(I_mosaic_cont_axis, show_xtick_labels=True, show_ytick_labels=True)
503
+ self.clean_axis_spines(V_mosaic_core_axis, show_xtick_labels=False, show_ytick_labels=False)
504
+ self.clean_axis_spines(V_mosaic_cont_axis, show_xtick_labels=True, show_ytick_labels=False)
505
+
506
+ if self.constants.correct_for_polarization:
507
+ V_mosaic_core_axis.coords.grid(
508
+ color="grey", linestyle=":", alpha=0.7, linewidth=0.3, zorder=1
509
+ )
510
+ V_mosaic_cont_axis.coords.grid(
511
+ color="grey", linestyle=":", alpha=0.7, linewidth=0.3, zorder=1
512
+ )
513
+
514
+ self.V_core_patch = self._add_patch_to_axis(
515
+ ax=V_mosaic_core_axis, corners=corners, color="blue"
516
+ )
517
+ self.V_cont_patch = self._add_patch_to_axis(
518
+ ax=V_mosaic_cont_axis, corners=corners, color="orange"
519
+ )
520
+ self.V_mosaic_core_info_text = self._add_ax_top_text(
521
+ ax=V_mosaic_core_axis,
522
+ text=self.format_V_mosaic_info_text(
523
+ wavelength=self.parameters.movie_core_wave_value_nm, vscale=core_vscale
524
+ ),
525
+ fontsize=5,
526
+ fontweight="bold",
527
+ )
528
+ self.V_mosaic_cont_info_text = self._add_ax_top_text(
529
+ ax=V_mosaic_cont_axis,
530
+ text=self.format_V_mosaic_info_text(
531
+ wavelength=self.parameters.movie_cont_wave_value_nm, vscale=cont_vscale
532
+ ),
533
+ fontsize=5,
534
+ fontweight="bold",
535
+ )
536
+
537
+ V_mosaic_core_axis.annotate(
538
+ "N",
539
+ (self.arrow_x, self.arrow_y),
540
+ xytext=(self.arrow_x, self.arrow_y + self.arrow_length),
541
+ arrowprops=dict(arrowstyle="<-", shrinkA=0, shrinkB=0, lw=0.5),
542
+ xycoords="figure fraction",
543
+ textcoords="figure fraction",
544
+ fontsize=6,
545
+ ha="center",
546
+ va="center",
547
+ )
548
+ V_mosaic_core_axis.annotate(
549
+ "E",
550
+ (self.arrow_x, self.arrow_y),
551
+ xytext=(
552
+ self.arrow_x - self.arrow_length / self.fig_width_inches * self.fig_height_inches,
553
+ self.arrow_y,
554
+ ),
555
+ arrowprops=dict(arrowstyle="<-", shrinkA=0, shrinkB=0, lw=0.5),
556
+ xycoords="figure fraction",
557
+ textcoords="figure fraction",
558
+ fontsize=6,
559
+ va="center",
560
+ ha="center",
561
+ )
562
+
563
+ def _init_stack_plots(
564
+ self, *, fig: plt.Figure, I_stack: np.ndarray, V_ratio: np.ndarray
565
+ ) -> plt.Axes:
566
+ """Initialize two plots of stacked, 2D spectra; one for Stokes I and V."""
567
+ I_stack_axis = fig.add_axes(
568
+ (
569
+ self.spec_left,
570
+ self.spec_bottom + self.spec_height + self.spec_v_padding,
571
+ self.spec_width,
572
+ self.stack_height,
573
+ )
574
+ )
575
+ V_stack_axis = fig.add_axes(
576
+ (
577
+ self.spec_left + self.spec_width + self.spec_h_padding,
578
+ self.spec_bottom + self.spec_height + self.spec_v_padding,
579
+ self.spec_width,
580
+ self.stack_height,
581
+ ),
582
+ sharex=I_stack_axis,
583
+ sharey=I_stack_axis,
584
+ )
585
+
586
+ I_stack_axis.set_facecolor("linen")
587
+ V_stack_axis.set_facecolor("black")
588
+
589
+ if not self.constants.correct_for_polarization:
590
+ V_stack_axis.set_facecolor("paleturquoise")
591
+ V_stack_axis.text(
592
+ 0.5,
593
+ 0.5,
594
+ "Intensity\nmode",
595
+ transform=V_stack_axis.transAxes,
596
+ fontsize=7,
597
+ ha="center",
598
+ va="center",
599
+ color="k",
600
+ )
601
+
602
+ for ax in [I_stack_axis, V_stack_axis]:
603
+ ax.xaxis.set_visible(False)
604
+ ax.yaxis.set_visible(False)
605
+
606
+ V_ratio -= np.nanmedian(V_ratio, axis=1)[:, None]
607
+
608
+ self.I_stack_im = I_stack_axis.imshow(
609
+ I_stack,
610
+ extent=(self.wave_abscissa[0], self.wave_abscissa[-1], 0, I_stack.shape[1]),
611
+ aspect="auto",
612
+ )
613
+ self.I_stack_im.set_clim(np.nanpercentile(I_stack, [1, 99]))
614
+ self.V_stack_im = V_stack_axis.imshow(
615
+ V_ratio,
616
+ extent=(self.wave_abscissa[0], self.wave_abscissa[-1], 0, V_ratio.shape[1]),
617
+ aspect="auto",
618
+ )
619
+ self.V_stack_im.set_clim(np.nanpercentile(V_ratio, [1, 99]))
620
+
621
+ self._add_ax_top_text(
622
+ ax=I_stack_axis,
623
+ text="Stokes I",
624
+ fontsize=6,
625
+ color="tab:orange",
626
+ )
627
+ self._add_ax_top_text(
628
+ ax=V_stack_axis,
629
+ text="Stokes V/I",
630
+ fontsize=6,
631
+ color="tab:orange",
632
+ )
633
+ return I_stack_axis
634
+
635
+ def _init_spectral_plots(
636
+ self, *, fig: plt.Figure, I_stack_axis: plt.Axes, I_stack: np.ndarray, V_ratio: np.ndarray
637
+ ):
638
+ """Initialize two plots showing the spatial median spectrum for Stokes I and V."""
639
+ I_spec_axis = fig.add_axes(
640
+ (self.spec_left, self.spec_bottom, self.spec_width, self.spec_height),
641
+ sharex=I_stack_axis,
642
+ )
643
+
644
+ I_spec_axis.yaxis.set_visible(False)
645
+ I_spec_axis.spines[["top", "left", "right"]].set_visible(False)
646
+ I_spec_axis.tick_params(axis="x", labelsize=4)
647
+
648
+ self.I_spec_line = I_spec_axis.plot(self.wave_abscissa, np.nanmedian(I_stack, axis=0))[0]
649
+ I_spec_axis.axvline(
650
+ self.parameters.movie_core_wave_value_nm, ls="dashed", color="blue", lw=0.6
651
+ )
652
+ I_spec_axis.axvline(
653
+ self.parameters.movie_cont_wave_value_nm, ls="dashed", color="orange", lw=0.6
654
+ )
655
+ I_spec_axis.set_ylim(0, 1.2)
656
+ I_spec_axis.text(
657
+ 0.02,
658
+ 1.14,
659
+ f"Median I",
660
+ ha="left",
661
+ va="top",
662
+ transform=I_spec_axis.transAxes,
663
+ color="tab:blue",
664
+ fontsize=5,
665
+ )
666
+ spec_plus_minus_wave_limit = np.round(
667
+ 0.45 * (self.wave_abscissa.max() - self.wave_abscissa.min()), 1
668
+ )
669
+ I_spec_axis.set_xticks(
670
+ np.round(
671
+ np.mean(self.wave_abscissa)
672
+ + np.array([-spec_plus_minus_wave_limit, 0, spec_plus_minus_wave_limit]),
673
+ 2,
674
+ )
675
+ )
676
+ I_spec_axis.set_xticklabels(
677
+ [
678
+ f"-{spec_plus_minus_wave_limit}",
679
+ f"{np.mean(self.wave_abscissa):.2f}nm",
680
+ f"+{spec_plus_minus_wave_limit}",
681
+ ]
682
+ )
683
+
684
+ if self.constants.correct_for_polarization:
685
+ V_spec_axis = fig.add_axes(
686
+ (
687
+ self.spec_left + self.spec_width + self.spec_h_padding,
688
+ self.spec_bottom,
689
+ self.spec_width,
690
+ self.spec_height,
691
+ ),
692
+ sharex=I_stack_axis,
693
+ )
694
+ V_spec_axis.yaxis.set_visible(False)
695
+ V_spec_axis.spines[["top", "left", "right"]].set_visible(False)
696
+ V_spec_axis.tick_params(axis="x", labelsize=4)
697
+
698
+ self.V_spec_line = V_spec_axis.plot(self.wave_abscissa, np.nanmedian(V_ratio, axis=0))[
699
+ 0
700
+ ]
701
+ V_spec_axis.axvline(
702
+ self.parameters.movie_core_wave_value_nm, ls="dashed", color="blue", lw=0.6
703
+ )
704
+ V_spec_axis.axvline(
705
+ self.parameters.movie_cont_wave_value_nm, ls="dashed", color="orange", lw=0.6
706
+ )
707
+ V_spec_axis.set_ylim(-self.vscale, self.vscale)
708
+ self.V_spec_title = V_spec_axis.text(
709
+ 0.02,
710
+ 1.14,
711
+ self.spec_V_info_text,
712
+ ha="left",
713
+ va="top",
714
+ transform=V_spec_axis.transAxes,
715
+ color="tab:blue",
716
+ fontsize=5,
717
+ )
718
+ self.V_spec_axis = V_spec_axis
719
+
720
+ @staticmethod
721
+ def _add_patch_to_axis(ax: WCSAxes, corners: np.ndarray, color: str) -> Polygon:
722
+ """Add a IFU footprint patch to a given axis."""
723
+ patch = ax.fill(
724
+ corners[:, 0],
725
+ corners[:, 1],
726
+ facecolor="none",
727
+ edgecolor=color,
728
+ linewidth=0.75,
729
+ alpha=0.7,
730
+ transform=ax.get_transform("world"),
731
+ zorder=2.5,
732
+ )[0]
733
+ return patch
734
+
735
+ @staticmethod
736
+ def _add_ax_top_text(
737
+ ax: Axes, text: str, fontsize: int, color: str = "k", fontweight: str = "normal"
738
+ ) -> Text:
739
+ """
740
+ Add text at the top center of an axis.
741
+
742
+ This lets us be a little tighter than `ax.set_title`
743
+ """
744
+ text = ax.text(
745
+ 0.5,
746
+ 1.01,
747
+ text,
748
+ fontsize=fontsize,
749
+ ha="center",
750
+ va="bottom",
751
+ color=color,
752
+ fontweight=fontweight,
753
+ transform=ax.transAxes,
754
+ )
755
+ return text
756
+
757
+ def build_mosaic_images(
758
+ self, data_list: list[list[np.ndarray]], wcs_list: list[WCS]
759
+ ) -> list[np.ndarray]:
760
+ """
761
+ Build mosaic images for a given set of data and WCS values.
762
+
763
+ Parameters
764
+ ----------
765
+ data_list
766
+ A list of lists containing `np.ndarray` objects. A separate mosaic image will be created for each list of
767
+ data in the top-level list.
768
+
769
+ wcs_list
770
+ A list of `astropy.wcs.WCS` objects. Should have the same length as the inner lists in ``data_list``.
771
+
772
+ Returns
773
+ -------
774
+ mosaic_list
775
+ A single mosaic array for each level in the outer list of ``data_list``.
776
+ """
777
+ logger.info("Stitching mosaics")
778
+ mosaic_list = []
779
+ with self.spherical_screen:
780
+ for i, dl in enumerate(data_list):
781
+ mosaic, _ = reproject_and_coadd(
782
+ tuple(zip(dl, wcs_list)),
783
+ self.reference_wcs,
784
+ reproject_function=reproject_interp,
785
+ shape_out=self.full_mosaic_shape,
786
+ blank_pixel_value=np.nan,
787
+ roundtrip_coords=False,
788
+ )
789
+ cleaned = interpolate_replace_nans(
790
+ mosaic,
791
+ np.ones(self.parameters.movie_nan_replacement_kernel_shape),
792
+ boundary="fill",
793
+ fill_value=np.nan,
794
+ )
795
+ mosaic_list.append(cleaned)
796
+
797
+ return mosaic_list
798
+
799
+ def read_mosaic_frames(
800
+ self, mosaic_num: int, stokes: str
801
+ ) -> tuple[list[list[np.ndarray]], list[WCS], list[str]]:
802
+ """
803
+ Read data and header info from all files for a given mosaic and Stokes parameter.
804
+
805
+ This function returns all data needed by other mosaic functions. In this way we only need to read the set of files
806
+ once.
807
+
808
+ Returns
809
+ -------
810
+ data_list
811
+ A list of lists containing `np.ndarray` objects. A separate mosaic image will be created for each list of
812
+ data in the top-level list.
813
+
814
+ wcs_list
815
+ A list of `astropy.wcs.WCS` objects. Should have the same length as the inner lists in ``data_list``.
816
+
817
+ date_beg_list
818
+ A list of DATE-BEG values from each mosaic file.
819
+ """
820
+ tags = [
821
+ DlnirspTag.calibrated(),
822
+ DlnirspTag.frame(),
823
+ DlnirspTag.mosaic_num(mosaic_num),
824
+ DlnirspTag.stokes(stokes),
825
+ ]
826
+ file_paths = self.read(tags=tags)
827
+ logger.info(f"Found {self.count(tags)} files for {mosaic_num = } and {stokes = }")
828
+ wcs_list = []
829
+ date_beg_list = []
830
+ data_list = [[], []]
831
+ for fo in file_paths:
832
+ hdul = fits.open(fo)
833
+ header = self.add_L1_header_values(hdul[0].header)
834
+ wcs_list.append(WCS(header).dropaxis(2))
835
+ date_beg_list.append(header["DATE-BEG"])
836
+
837
+ for i, s in enumerate([self.core_wave_idx, self.cont_wave_idx]):
838
+ data = hdul[0].data[s, :, :]
839
+ for vs in self.parameters.movie_vertical_nan_slices:
840
+ data[vs, :] = np.nan
841
+ data_list[i].append(data)
842
+
843
+ del hdul
844
+
845
+ return data_list, wcs_list, date_beg_list
846
+
847
+ def compute_mosaic_timing(self, date_beg_list: list[str]) -> tuple[datetime, timedelta]:
848
+ """
849
+ Compute the start time and duration of a mosaic from a list of DATE-BEG values for frames in the mosaic.
850
+
851
+ Returns
852
+ -------
853
+ start_time
854
+ The start `datetime` of the mosaic
855
+
856
+ duration
857
+ Duration of the mosaic
858
+ """
859
+ datetime_list = [datetime.fromisoformat(i) for i in date_beg_list]
860
+ start_time = min(datetime_list)
861
+ end_time = max(datetime_list)
862
+ duration = end_time - start_time
863
+
864
+ return start_time, duration
865
+
866
+ def compute_step_wcs_box(self, file_path: Path) -> np.ndarray:
867
+ """
868
+ Compute the spatial "box" representing a single mosaic step given a header from that step.
869
+
870
+ The WCS footprint is read directly from the FITS header and then converted to the coordinate frame of the
871
+ reference WCS.
872
+
873
+ Returns
874
+ -------
875
+ corners
876
+ Array of shape (4, 2) containing (x, y) locations of 4 corners of the box representing the pointing of the
877
+ particular mosaic step. The values are in WCS coordinates.
878
+ """
879
+ header = self.add_L1_header_values(fits.getheader(file_path))
880
+ wcs = WCS(header).dropaxis(2)
881
+ local_corners = wcs.calc_footprint()
882
+ footprint_in_local_wcs = SkyCoord(
883
+ *local_corners.T, unit=u.deg, frame=wcs_to_celestial_frame(wcs)
884
+ )
885
+ with self.spherical_screen:
886
+ footprint_in_ref_wcs = footprint_in_local_wcs.transform_to(self.reference_coord_frame)
887
+ ref_corners = (
888
+ u.Quantity([footprint_in_ref_wcs.Tx, footprint_in_ref_wcs.Ty]).to_value(u.deg).T
889
+ )
890
+
891
+ return ref_corners
892
+
893
+ def get_stacked_spectra(self, file_path: Path) -> np.ndarray:
894
+ """Read a file for a single mosaic step and flatten the 3D cube into a stacked, 2D spectral array."""
895
+ data = fits.getdata(file_path)
896
+ stack = data.reshape(self.num_wave, self.num_spec).T
897
+ return stack
898
+
899
+ @staticmethod
900
+ def clean_axis_spines(axis: WCSAxes, show_xtick_labels: bool, show_ytick_labels: bool) -> None:
901
+ """
902
+ Clean extra ticks and (optionally) tick labels from a given axis.
903
+
904
+ The top and right ticks are always turned off. The ``show_[xy]tick_labels`` flags optionally turn off the value
905
+ labels of the bottom and left axes.
906
+ """
907
+ lon, lat = axis.coords
908
+ lon.set_ticks_position("b")
909
+ lat.set_ticks_position("l")
910
+ if not show_xtick_labels:
911
+ lon.set_ticklabel_visible(False)
912
+ if not show_ytick_labels:
913
+ lat.set_ticklabel_visible(False)
914
+
915
+ def maximize_mosaic_axis_area(self, axis: WCSAxes) -> None:
916
+ """
917
+ Expand axis limits to ensure the axis always fills its "box".
918
+
919
+ `imshow` will auto-tighten the limits to the image area, but we want the mosaic axes to always take up the same
920
+ space in the figure, regardless of the mosaic aspect ratio.
921
+ """
922
+ full_ax_aspect = (
923
+ self.fig_width_inches / self.fig_height_inches * self.mosaic_width / self.mosaic_height
924
+ )
925
+ x_min, x_max = axis.get_xlim()
926
+ y_min, y_max = axis.get_ylim()
927
+ img_aspect = (x_max - x_min) / (y_max - y_min)
928
+
929
+ if img_aspect > full_ax_aspect:
930
+ # Image is wider → expand ylim
931
+ y_center = 0.5 * (y_min + y_max)
932
+ y_span = (x_max - x_min) / full_ax_aspect
933
+ axis.set_ylim(y_center - y_span / 2, y_center + y_span / 2)
934
+ else:
935
+ # Image is taller → expand xlim
936
+ x_center = 0.5 * (x_min + x_max)
937
+ x_span = (y_max - y_min) * full_ax_aspect
938
+ axis.set_xlim(x_center - x_span / 2, x_center + x_span / 2)
939
+
940
+ def pad_mosaic_axis(self, axis: WCSAxes) -> None:
941
+ """
942
+ Expand axis limits by a small amount so the mosaic image doesn't touch the axis lines.
943
+
944
+ Because of how matplotlib animation works, the image can end up looking "on top" of the axis frame if the limits
945
+ are super tight.
946
+ """
947
+ xrange = np.abs(np.diff(axis.get_xlim()))[0]
948
+ padded_xlim = (
949
+ np.array(axis.get_xlim())
950
+ + np.array([-xrange, xrange]) * self.fractional_mosaic_axis_limit_pad
951
+ )
952
+ axis.set_xlim(*padded_xlim)
953
+
954
+ yrange = np.abs(np.diff(axis.get_ylim()))[0]
955
+ padded_ylim = (
956
+ np.array(axis.get_ylim())
957
+ + np.array([-yrange, yrange]) * self.fractional_mosaic_axis_limit_pad
958
+ )
959
+ axis.set_ylim(*padded_ylim)
960
+
961
+ def format_title_text(self, mosaic_start_time: datetime, mosaic_duration: timedelta) -> str:
962
+ """Return the top-level figure title text."""
963
+ pol_str = (
964
+ "Full Stokes Polarimetry (Q&U not shown)"
965
+ if self.constants.correct_for_polarization
966
+ else "Stokes I Only"
967
+ )
968
+ title = (
969
+ f"ArmID: {self.constants.arm_id} -- {self.spectral_line_name} -- {pol_str}"
970
+ f"\nProduct ID: {self.product_id} --- Experiment ID: {self.constants.experiment_id}"
971
+ f"\nStart Time: {self.constants.obs_ip_start_time} --- End Time: {self.constants.obs_ip_end_time}"
972
+ f"\nMosaic {self.currently_shown_mosaic + 1} of {self.constants.num_mosaic_repeats} -> start time: {mosaic_start_time.isoformat("T")} - Duration: {mosaic_duration.total_seconds():.2f} sec"
973
+ )
974
+ return title
975
+
976
+ def format_V_mosaic_info_text(self, wavelength: float, vscale: float) -> str:
977
+ """
978
+ Format the info text for V mosaic images given the current mosaic's vscale parameter.
979
+
980
+ Wavelength is a parameter so this can be used for both core and continuum images.
981
+ """
982
+ return rf"Stokes V/I [$\lambda = ${wavelength:.2f} nm] Saturation @ ± {vscale * 100.:.2f}%"
983
+
984
+ def format_step_info_text(self, date_beg: str, mindex1: int, mindex2: int) -> str:
985
+ """
986
+ Format the info text for a single mosaic step.
987
+
988
+ Shows the DATE-BEG and mosaic index position.
989
+ """
990
+ return f"Mosaic index ({mindex1}, {mindex2})\n{date_beg}"
991
+
992
+ @property
993
+ def spec_V_info_text(self) -> str:
994
+ """Return the title of the V median spectrum plot with the appropriate vscale."""
995
+ return f"Median V/I [±{self.vscale * 100:.2f}%]"
996
+
997
+ def add_L1_header_values(self, header) -> fits.Header:
998
+ """Add the L1 header values that are needed to compute accurate WCSs."""
999
+ header["DATEREF"] = header["DATE-BEG"]
1000
+ header["OBSGEO-X"] = location_of_dkist.x.to_value(u.m)
1001
+ header["OBSGEO-Y"] = location_of_dkist.y.to_value(u.m)
1002
+ header["OBSGEO-Z"] = location_of_dkist.z.to_value(u.m)
1003
+ obstime = Time(header["DATE-BEG"])
1004
+ header["OBS_VR"] = (
1005
+ location_of_dkist.get_gcrs(obstime=obstime)
1006
+ .transform_to(HeliocentricInertial(obstime=obstime))
1007
+ .d_distance.to_value(u.m / u.s)
1008
+ )
1009
+
1010
+ dkist_at_date_beg = location_of_dkist.get_itrs(obstime=obstime)
1011
+ sun_coordinate = Helioprojective(
1012
+ Tx=0 * u.arcsec, Ty=0 * u.arcsec, observer=dkist_at_date_beg
1013
+ )
1014
+ header["SOLARRAD"] = round(sun_coordinate.angular_radius.value, 2)
1015
+
1016
+ header["SPECSYS"] = "TOPOCENT"
1017
+ header["VELOSYS"] = 0.0
1018
+
1019
+ header.update(self.wavelength_solution)
1020
+
1021
+ return header
1022
+
1023
+
1024
+ def update_plot(frame_index: int, task_obj: MakeDlnirspMovie, is_polarimetric: bool):
1025
+ """
1026
+ Update the movie figure with data from the current mosaic step.
1027
+
1028
+ The stack and spectral axis are always updated, as are the boxes that show the location of the current step.
1029
+ If the current step is the first in a new mosaic then the mosaic images are also updated.
1030
+ """
1031
+ I_path = task_obj.I_file_list[frame_index]
1032
+
1033
+ I_obj = DlnirspL0FitsAccess.from_path(I_path)
1034
+ current_mosaic = I_obj.mosaic_num
1035
+
1036
+ logger.info(
1037
+ f"Plotting step {frame_index} / {task_obj.num_movie_frames}. Mosaic {current_mosaic} / {task_obj.constants.num_mosaic_repeats}"
1038
+ )
1039
+ if current_mosaic != task_obj.currently_shown_mosaic:
1040
+ logger.info(f"Creating mosaic number {current_mosaic}")
1041
+ I_mosaic_data, I_mosaic_WCS, I_mosaic_dates = task_obj.read_mosaic_frames(
1042
+ mosaic_num=current_mosaic, stokes="I"
1043
+ )
1044
+ I_mosaic_list = task_obj.build_mosaic_images(data_list=I_mosaic_data, wcs_list=I_mosaic_WCS)
1045
+
1046
+ core_iscales = np.nanpercentile(I_mosaic_list[0], [5, 95]) + np.array([-0.1, 0.1])
1047
+ cont_iscales = np.nanpercentile(I_mosaic_list[1], [5, 95]) + np.array([-0.1, 0.1])
1048
+
1049
+ task_obj.I_mosaic_core_im.set_data(I_mosaic_list[0])
1050
+ task_obj.I_mosaic_cont_im.set_data(I_mosaic_list[1])
1051
+ task_obj.I_mosaic_core_im.set_clim(*core_iscales)
1052
+ task_obj.I_mosaic_cont_im.set_clim(*cont_iscales)
1053
+
1054
+ if is_polarimetric:
1055
+ V_mosaic_data, V_mosaic_WCS, _ = task_obj.read_mosaic_frames(
1056
+ mosaic_num=current_mosaic, stokes="V"
1057
+ )
1058
+ V_mosaic_list = task_obj.build_mosaic_images(
1059
+ data_list=V_mosaic_data, wcs_list=V_mosaic_WCS
1060
+ )
1061
+
1062
+ V_core_ratio = V_mosaic_list[0] / I_mosaic_list[0]
1063
+ V_cont_ratio = V_mosaic_list[1] / I_mosaic_list[1]
1064
+
1065
+ core_vscale = np.nanpercentile(np.abs(V_core_ratio), 95)
1066
+ cont_vscale = np.nanpercentile(np.abs(V_cont_ratio), 95)
1067
+ task_obj.vscale = max([core_vscale, cont_vscale])
1068
+
1069
+ task_obj.V_mosaic_core_im.set_data(V_core_ratio)
1070
+ task_obj.V_mosaic_cont_im.set_data(V_cont_ratio)
1071
+ task_obj.V_mosaic_core_im.set_clim(-core_vscale, core_vscale)
1072
+ task_obj.V_mosaic_cont_im.set_clim(-cont_vscale, cont_vscale)
1073
+
1074
+ task_obj.V_mosaic_core_info_text.set_text(
1075
+ task_obj.format_V_mosaic_info_text(
1076
+ wavelength=task_obj.parameters.movie_core_wave_value_nm, vscale=core_vscale
1077
+ )
1078
+ )
1079
+ task_obj.V_mosaic_cont_info_text.set_text(
1080
+ task_obj.format_V_mosaic_info_text(
1081
+ wavelength=task_obj.parameters.movie_cont_wave_value_nm, vscale=cont_vscale
1082
+ )
1083
+ )
1084
+ task_obj.V_spec_title.set_text(task_obj.spec_V_info_text)
1085
+
1086
+ task_obj.currently_shown_mosaic = current_mosaic
1087
+
1088
+ mosaic_start_time, mosaic_duration = task_obj.compute_mosaic_timing(I_mosaic_dates)
1089
+ task_obj.mosaic_info_title.set_text(
1090
+ task_obj.format_title_text(mosaic_start_time, mosaic_duration)
1091
+ )
1092
+
1093
+ corners = task_obj.compute_step_wcs_box(I_path)
1094
+
1095
+ I_stack = task_obj.get_stacked_spectra(I_path)
1096
+ task_obj.I_stack_im.set_data(I_stack)
1097
+ task_obj.I_stack_im.set_clim(np.nanpercentile(I_stack, [1, 99]))
1098
+
1099
+ task_obj.I_spec_line.set_ydata(np.nanmedian(I_stack, axis=0))
1100
+
1101
+ task_obj.I_core_patch.set_xy(corners)
1102
+ task_obj.I_cont_patch.set_xy(corners)
1103
+
1104
+ if is_polarimetric:
1105
+ V_path = task_obj.V_file_list[frame_index]
1106
+ V_stack = task_obj.get_stacked_spectra(V_path)
1107
+ V_ratio = V_stack / I_stack
1108
+ V_ratio -= np.nanmedian(V_ratio, axis=1)[:, None]
1109
+ task_obj.V_stack_im.set_data(V_ratio)
1110
+ task_obj.V_stack_im.set_clim(np.nanpercentile(V_ratio, [1, 99]))
1111
+ task_obj.V_spec_line.set_ydata(np.nanmedian(V_ratio, axis=0))
1112
+ task_obj.V_spec_axis.set_ylim(-task_obj.vscale, task_obj.vscale)
1113
+ task_obj.V_core_patch.set_xy(corners)
1114
+ task_obj.V_cont_patch.set_xy(corners)
1115
+
1116
+ # TODO: Metadata key?
1117
+ mindex1 = I_obj.header["MINDEX1"]
1118
+ mindex2 = I_obj.header["MINDEX2"]
1119
+ task_obj.step_info_title.set_text(
1120
+ task_obj.format_step_info_text(date_beg=I_obj.time_obs, mindex1=mindex1, mindex2=mindex2)
1121
+ )