solarviewer 1.0.2__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 (82) hide show
  1. solar_radio_image_viewer/__init__.py +12 -0
  2. solar_radio_image_viewer/assets/add_tab_default.png +0 -0
  3. solar_radio_image_viewer/assets/add_tab_default_light.png +0 -0
  4. solar_radio_image_viewer/assets/add_tab_hover.png +0 -0
  5. solar_radio_image_viewer/assets/add_tab_hover_light.png +0 -0
  6. solar_radio_image_viewer/assets/browse.png +0 -0
  7. solar_radio_image_viewer/assets/browse_light.png +0 -0
  8. solar_radio_image_viewer/assets/close_tab_default.png +0 -0
  9. solar_radio_image_viewer/assets/close_tab_default_light.png +0 -0
  10. solar_radio_image_viewer/assets/close_tab_hover.png +0 -0
  11. solar_radio_image_viewer/assets/close_tab_hover_light.png +0 -0
  12. solar_radio_image_viewer/assets/ellipse_selection.png +0 -0
  13. solar_radio_image_viewer/assets/ellipse_selection_light.png +0 -0
  14. solar_radio_image_viewer/assets/icons8-ellipse-90.png +0 -0
  15. solar_radio_image_viewer/assets/icons8-ellipse-90_light.png +0 -0
  16. solar_radio_image_viewer/assets/icons8-info-90.png +0 -0
  17. solar_radio_image_viewer/assets/icons8-info-90_light.png +0 -0
  18. solar_radio_image_viewer/assets/profile.png +0 -0
  19. solar_radio_image_viewer/assets/profile_light.png +0 -0
  20. solar_radio_image_viewer/assets/rectangle_selection.png +0 -0
  21. solar_radio_image_viewer/assets/rectangle_selection_light.png +0 -0
  22. solar_radio_image_viewer/assets/reset.png +0 -0
  23. solar_radio_image_viewer/assets/reset_light.png +0 -0
  24. solar_radio_image_viewer/assets/ruler.png +0 -0
  25. solar_radio_image_viewer/assets/ruler_light.png +0 -0
  26. solar_radio_image_viewer/assets/search.png +0 -0
  27. solar_radio_image_viewer/assets/search_light.png +0 -0
  28. solar_radio_image_viewer/assets/settings.png +0 -0
  29. solar_radio_image_viewer/assets/settings_light.png +0 -0
  30. solar_radio_image_viewer/assets/splash.fits +0 -0
  31. solar_radio_image_viewer/assets/zoom_60arcmin.png +0 -0
  32. solar_radio_image_viewer/assets/zoom_60arcmin_light.png +0 -0
  33. solar_radio_image_viewer/assets/zoom_in.png +0 -0
  34. solar_radio_image_viewer/assets/zoom_in_light.png +0 -0
  35. solar_radio_image_viewer/assets/zoom_out.png +0 -0
  36. solar_radio_image_viewer/assets/zoom_out_light.png +0 -0
  37. solar_radio_image_viewer/create_video.py +1345 -0
  38. solar_radio_image_viewer/dialogs.py +2665 -0
  39. solar_radio_image_viewer/from_simpl/__init__.py +184 -0
  40. solar_radio_image_viewer/from_simpl/caltable_visualizer.py +1001 -0
  41. solar_radio_image_viewer/from_simpl/dynamic_spectra_dialog.py +332 -0
  42. solar_radio_image_viewer/from_simpl/make_dynamic_spectra.py +351 -0
  43. solar_radio_image_viewer/from_simpl/pipeline_logger_gui.py +1232 -0
  44. solar_radio_image_viewer/from_simpl/simpl_theme.py +352 -0
  45. solar_radio_image_viewer/from_simpl/utils.py +984 -0
  46. solar_radio_image_viewer/from_simpl/view_dynamic_spectra_GUI.py +1975 -0
  47. solar_radio_image_viewer/helioprojective.py +1916 -0
  48. solar_radio_image_viewer/helioprojective_viewer.py +817 -0
  49. solar_radio_image_viewer/helioviewer_browser.py +1514 -0
  50. solar_radio_image_viewer/main.py +148 -0
  51. solar_radio_image_viewer/move_phasecenter.py +1269 -0
  52. solar_radio_image_viewer/napari_viewer.py +368 -0
  53. solar_radio_image_viewer/noaa_events/__init__.py +32 -0
  54. solar_radio_image_viewer/noaa_events/noaa_events.py +430 -0
  55. solar_radio_image_viewer/noaa_events/noaa_events_gui.py +1922 -0
  56. solar_radio_image_viewer/norms.py +293 -0
  57. solar_radio_image_viewer/radio_data_downloader/__init__.py +25 -0
  58. solar_radio_image_viewer/radio_data_downloader/radio_data_downloader.py +756 -0
  59. solar_radio_image_viewer/radio_data_downloader/radio_data_downloader_gui.py +528 -0
  60. solar_radio_image_viewer/searchable_combobox.py +220 -0
  61. solar_radio_image_viewer/solar_context/__init__.py +41 -0
  62. solar_radio_image_viewer/solar_context/active_regions.py +371 -0
  63. solar_radio_image_viewer/solar_context/cme_alerts.py +234 -0
  64. solar_radio_image_viewer/solar_context/context_images.py +297 -0
  65. solar_radio_image_viewer/solar_context/realtime_data.py +528 -0
  66. solar_radio_image_viewer/solar_data_downloader/__init__.py +35 -0
  67. solar_radio_image_viewer/solar_data_downloader/solar_data_downloader.py +1667 -0
  68. solar_radio_image_viewer/solar_data_downloader/solar_data_downloader_cli.py +901 -0
  69. solar_radio_image_viewer/solar_data_downloader/solar_data_downloader_gui.py +1210 -0
  70. solar_radio_image_viewer/styles.py +643 -0
  71. solar_radio_image_viewer/utils/__init__.py +32 -0
  72. solar_radio_image_viewer/utils/rate_limiter.py +255 -0
  73. solar_radio_image_viewer/utils.py +952 -0
  74. solar_radio_image_viewer/video_dialog.py +2629 -0
  75. solar_radio_image_viewer/video_utils.py +656 -0
  76. solar_radio_image_viewer/viewer.py +11174 -0
  77. solarviewer-1.0.2.dist-info/METADATA +343 -0
  78. solarviewer-1.0.2.dist-info/RECORD +82 -0
  79. solarviewer-1.0.2.dist-info/WHEEL +5 -0
  80. solarviewer-1.0.2.dist-info/entry_points.txt +8 -0
  81. solarviewer-1.0.2.dist-info/licenses/LICENSE +21 -0
  82. solarviewer-1.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,952 @@
1
+ import os
2
+ import numpy as np
3
+
4
+ # Try to import CASA tools & tasks
5
+ try:
6
+ # Suppress CASA logging warnings before importing casatools
7
+ import os as _os
8
+ _os.environ['CASA_LOGLEVEL'] = 'ERROR'
9
+ _os.environ['CASARC'] = '/dev/null'
10
+
11
+ from casatools import image as IA
12
+ from casatasks import immath
13
+
14
+ # Configure CASA logging to suppress warnings
15
+ try:
16
+ from casatools import logsink
17
+ _casalog = logsink()
18
+ _casalog.setlogfile('/dev/null') # Redirect CASA logs to null
19
+ _casalog.setglobal(True)
20
+ # Filter out WARN and INFO level messages
21
+ _casalog.filter('ERROR')
22
+ except Exception:
23
+ pass
24
+
25
+ CASA_AVAILABLE = True
26
+ except ImportError:
27
+ print(
28
+ "WARNING: CASA tools not found. This application requires CASA to be installed."
29
+ )
30
+ CASA_AVAILABLE = False
31
+ IA = None
32
+ immath = None
33
+
34
+ # Try to import scipy
35
+ try:
36
+ from scipy.optimize import curve_fit
37
+
38
+ SCIPY_AVAILABLE = True
39
+ except ImportError:
40
+ print("WARNING: scipy not found. Fitting functionality will be disabled.")
41
+ SCIPY_AVAILABLE = False
42
+ curve_fit = None
43
+
44
+ # Try to import astropy
45
+ try:
46
+ from astropy.wcs import WCS
47
+ import astropy.units as u
48
+
49
+ ASTROPY_AVAILABLE = True
50
+ except ImportError:
51
+ print("WARNING: astropy not found. Some functionality will be limited.")
52
+ ASTROPY_AVAILABLE = False
53
+ WCS = None
54
+ u = None
55
+
56
+
57
+ def estimate_rms_near_Sun(imagename, stokes="I", box=(0, 200, 0, 130)):
58
+ stokes_map = {"I": 0, "Q": 1, "U": 2, "V": 3}
59
+ ia_tool = IA()
60
+ ia_tool.open(imagename)
61
+ summary = ia_tool.summary()
62
+ dimension_names = summary["axisnames"]
63
+
64
+ ra_idx = np.where(dimension_names == "Right Ascension")[0][0]
65
+ dec_idx = np.where(dimension_names == "Declination")[0][0]
66
+
67
+ stokes_idx = None
68
+ freq_idx = None
69
+ if "Stokes" in dimension_names:
70
+ stokes_idx = np.where(np.array(dimension_names) == "Stokes")[0][0]
71
+ if "Frequency" in dimension_names:
72
+ freq_idx = np.where(np.array(dimension_names) == "Frequency")[0][0]
73
+
74
+ data = ia_tool.getchunk()
75
+ ia_tool.close()
76
+
77
+ if stokes_idx is not None:
78
+ idx = stokes_map.get(stokes, 0)
79
+ slice_list = [slice(None)] * len(data.shape)
80
+ slice_list[stokes_idx] = idx
81
+
82
+ if freq_idx is not None:
83
+ slice_list[freq_idx] = 0
84
+
85
+ stokes_data = data[tuple(slice_list)]
86
+ else:
87
+ stokes_data = data
88
+
89
+ x1, x2, y1, y2 = box
90
+ region_slice = [slice(None)] * len(stokes_data.shape)
91
+ region_slice[ra_idx] = slice(x1, x2)
92
+ region_slice[dec_idx] = slice(y1, y2)
93
+ region = stokes_data[tuple(region_slice)]
94
+ if region.size == 0:
95
+ return 0.0
96
+ rms = np.sqrt(np.mean(region**2))
97
+ return rms
98
+
99
+
100
+ def remove_pixels_away_from_sun(pix, csys, radius_arcmin=55):
101
+ rad_to_deg = 180.0 / np.pi
102
+ # Use astropy's WCS for coordinate conversion
103
+ from astropy.wcs import WCS
104
+
105
+ w = WCS(naxis=2)
106
+ w.wcs.cdelt = csys.increment()["numeric"][0:2] * rad_to_deg
107
+ radius_deg = radius_arcmin / 60.0
108
+ delta_deg = abs(w.wcs.cdelt[0])
109
+ pixel_radius = radius_deg / delta_deg
110
+
111
+ cx = pix.shape[0] / 2
112
+ cy = pix.shape[1] / 2
113
+ y, x = np.ogrid[: pix.shape[1], : pix.shape[0]]
114
+ mask = (x - cx) ** 2 + (y - cy) ** 2 > pixel_radius**2
115
+ pix[mask] = 0
116
+ return pix
117
+
118
+
119
+ # TODO: Handle single stokes case, return flag so that some features can be disabled
120
+
121
+
122
+ def get_pixel_values_from_image(
123
+ imagename,
124
+ stokes,
125
+ thres,
126
+ rms_box=(0, 200, 0, 130),
127
+ stokes_map={"I": 0, "Q": 1, "U": 2, "V": 3},
128
+ ):
129
+ """
130
+ Retrieve pixel values from a CASA image with proper error handling and dimension checks.
131
+
132
+ Parameters:
133
+ imagename : str
134
+ Path to the CASA image directory.
135
+ stokes : str
136
+ The stokes parameter to extract ("I", "Q", "U", "V", "L", "Lfrac", "Vfrac", "Q/I", "U/I", "U/V", or "PANG").
137
+ thres : float
138
+ Threshold value.
139
+ rms_box : tuple, optional
140
+ Region coordinates (x1, x2, y1, y2) for RMS estimation.
141
+ stokes_map : dict, optional
142
+ Mapping of standard stokes parameters to their corresponding axis indices.
143
+
144
+ Returns:
145
+ pix : numpy.ndarray
146
+ The extracted pixel data.
147
+ csys : object
148
+ Coordinate system object from CASA.
149
+ psf : object
150
+ Beam information from CASA.
151
+
152
+ Raises:
153
+ RuntimeError: For errors in reading the image or if required dimensions are missing.
154
+ """
155
+
156
+ if not CASA_AVAILABLE:
157
+ raise RuntimeError("CASA is not available")
158
+
159
+ single_stokes_flag = False
160
+ try:
161
+ ia_tool = IA()
162
+ ia_tool.open(imagename)
163
+ except Exception as e:
164
+ raise RuntimeError(f"Failed to open image {imagename}: {e}")
165
+
166
+ try:
167
+ summary = ia_tool.summary()
168
+ dimension_names = summary.get("axisnames")
169
+ dimension_shapes = summary.get("shape")
170
+ if dimension_names is None:
171
+ raise ValueError("Image summary does not contain 'axisnames'")
172
+ # Ensure we can index; convert to numpy array if needed
173
+ dimension_names = np.array(dimension_names)
174
+
175
+ if "Right Ascension" in dimension_names:
176
+ try:
177
+ ra_idx = int(np.where(dimension_names == "Right Ascension")[0][0])
178
+ except IndexError:
179
+ raise ValueError("Right Ascension axis not found in image summary.")
180
+
181
+ try:
182
+ dec_idx = int(np.where(dimension_names == "Declination")[0][0])
183
+ except IndexError:
184
+ raise ValueError("Declination axis not found in image summary.")
185
+
186
+ if "Stokes" in dimension_names:
187
+ stokes_idx = int(np.where(dimension_names == "Stokes")[0][0])
188
+ if dimension_shapes[stokes_idx] == 1:
189
+ single_stokes_flag = True
190
+ else:
191
+ # Assume single stokes; set index to 0
192
+ stokes_idx = None
193
+ single_stokes_flag = True
194
+
195
+ if "Frequency" in dimension_names:
196
+ freq_idx = int(np.where(dimension_names == "Frequency")[0][0])
197
+ else:
198
+ # If Frequency axis is missing, assume index 0
199
+ freq_idx = None
200
+
201
+ data = ia_tool.getchunk()
202
+ psf = ia_tool.restoringbeam()
203
+ csys = ia_tool.coordsys()
204
+ if "SOLAR-X" in dimension_names:
205
+ try:
206
+ ra_idx = int(np.where(dimension_names == "SOLAR-X")[0][0])
207
+ except IndexError:
208
+ raise ValueError("SOLAR-X axis not found in image summary.")
209
+ try:
210
+ dec_idx = int(np.where(dimension_names == "SOLAR-Y")[0][0])
211
+ except IndexError:
212
+ raise ValueError("SOLAR-Y axis not found in image summary.")
213
+
214
+ if "Stokes" in dimension_names:
215
+ stokes_idx = int(np.where(dimension_names == "Stokes")[0][0])
216
+ if dimension_shapes[stokes_idx] == 1:
217
+ single_stokes_flag = True
218
+ else:
219
+ # Assume single stokes; set index to 0
220
+ stokes_idx = None
221
+ if "Frequency" in dimension_names:
222
+ freq_idx = int(np.where(dimension_names == "Frequency")[0][0])
223
+ else:
224
+ # If Frequency axis is missing, assume index 0
225
+ freq_idx = None
226
+ data = ia_tool.getchunk()
227
+ psf = ia_tool.restoringbeam()
228
+ csys = ia_tool.coordsys()
229
+
230
+ except Exception as e:
231
+ ia_tool.close()
232
+ raise RuntimeError(f"Error reading image metadata: {e}")
233
+ ia_tool.close()
234
+
235
+ # Verify that our slice indices are within data dimensions
236
+ n_dims = len(data.shape)
237
+ if stokes_idx is not None and (stokes_idx >= n_dims):
238
+ raise RuntimeError(
239
+ "The determined axis index is out of bounds for the image data."
240
+ )
241
+ if freq_idx is not None and (freq_idx >= n_dims):
242
+ raise RuntimeError(
243
+ "The determined axis index is out of bounds for the image data."
244
+ )
245
+
246
+ # Process based on stokes type
247
+ if stokes in ["I", "Q", "U", "V"]:
248
+ idx = stokes_map.get(stokes)
249
+ if idx is None:
250
+ raise ValueError(f"Unknown Stokes parameter: {stokes}")
251
+ slice_list = [slice(None)] * n_dims
252
+ if stokes_idx is not None:
253
+ if single_stokes_flag:
254
+ if stokes != "I":
255
+ raise RuntimeError(
256
+ "The image is single stokes, but the Stokes parameter is not 'I'."
257
+ )
258
+ slice_list[stokes_idx] = idx
259
+ if freq_idx is not None:
260
+ slice_list[freq_idx] = 0
261
+ pix = data[tuple(slice_list)]
262
+ elif stokes == "L":
263
+ if stokes_idx is None:
264
+ raise RuntimeError("The image does not have a Stokes axis.")
265
+ elif single_stokes_flag:
266
+ raise RuntimeError(
267
+ "The image is single stokes, but the Stokes parameter is not 'I'."
268
+ )
269
+ slice_list_Q = [slice(None)] * n_dims
270
+ slice_list_U = [slice(None)] * n_dims
271
+ slice_list_Q[stokes_idx] = 1
272
+ slice_list_U[stokes_idx] = 2
273
+ slice_list_Q[freq_idx] = 0
274
+ slice_list_U[freq_idx] = 0
275
+ pix_Q = data[tuple(slice_list_Q)]
276
+ pix_U = data[tuple(slice_list_U)]
277
+ pix = np.sqrt(pix_Q**2 + pix_U**2)
278
+ elif stokes == "Lfrac":
279
+ if stokes_idx is None:
280
+ raise RuntimeError("The image does not have a Stokes axis.")
281
+ elif single_stokes_flag:
282
+ raise RuntimeError(
283
+ "The image is single stokes, but the Stokes parameter is not 'I'."
284
+ )
285
+ outfile = "temp_p_map.im"
286
+ try:
287
+ immath(imagename=imagename, outfile=outfile, mode="lpoli")
288
+ p_rms = estimate_rms_near_Sun(outfile, "I", rms_box)
289
+ except Exception as e:
290
+ raise RuntimeError(f"Error generating polarization map: {e}")
291
+ finally:
292
+ os.system(f"rm -rf {outfile}")
293
+ slice_list_Q = [slice(None)] * n_dims
294
+ slice_list_U = [slice(None)] * n_dims
295
+ slice_list_I = [slice(None)] * n_dims
296
+ slice_list_Q[stokes_idx] = 1
297
+ slice_list_U[stokes_idx] = 2
298
+ slice_list_I[stokes_idx] = 0
299
+ slice_list_Q[freq_idx] = 0
300
+ slice_list_U[freq_idx] = 0
301
+ slice_list_I[freq_idx] = 0
302
+ pix_Q = data[tuple(slice_list_Q)]
303
+ pix_U = data[tuple(slice_list_U)]
304
+ pix_I = data[tuple(slice_list_I)]
305
+ pvals = np.sqrt(pix_Q**2 + pix_U**2)
306
+ mask = pvals < (thres * p_rms)
307
+ pvals[mask] = 0
308
+ pix = pvals / pix_I
309
+ pix = remove_pixels_away_from_sun(pix, csys, 55)
310
+ elif stokes == "Vfrac":
311
+ if stokes_idx is None:
312
+ raise RuntimeError("The image does not have a Stokes axis.")
313
+ elif single_stokes_flag:
314
+ raise RuntimeError(
315
+ "The image is single stokes, but the Stokes parameter is not 'I'."
316
+ )
317
+ slice_list_V = [slice(None)] * n_dims
318
+ slice_list_I = [slice(None)] * n_dims
319
+ slice_list_V[stokes_idx] = 3
320
+ slice_list_I[stokes_idx] = 0
321
+ if freq_idx is not None:
322
+ slice_list_V[freq_idx] = 0
323
+ slice_list_I[freq_idx] = 0
324
+ pix_V = data[tuple(slice_list_V)]
325
+ pix_I = data[tuple(slice_list_I)]
326
+ v_rms = estimate_rms_near_Sun(imagename, "V", rms_box)
327
+ mask = np.abs(pix_V) < (thres * v_rms)
328
+ pix_V[mask] = 0
329
+ pix = pix_V / pix_I
330
+ pix = remove_pixels_away_from_sun(pix, csys, 55)
331
+ elif stokes == "Q/I":
332
+ if stokes_idx is None:
333
+ raise RuntimeError("The image does not have a Stokes axis.")
334
+ elif single_stokes_flag:
335
+ raise RuntimeError(
336
+ "The image is single stokes, but the Stokes parameter is not 'I'."
337
+ )
338
+ q_rms = estimate_rms_near_Sun(imagename, "Q", rms_box)
339
+ slice_list_Q = [slice(None)] * n_dims
340
+ slice_list_I = [slice(None)] * n_dims
341
+ slice_list_Q[stokes_idx] = 1
342
+ slice_list_I[stokes_idx] = 0
343
+ if freq_idx is not None:
344
+ slice_list_Q[freq_idx] = 0
345
+ slice_list_I[freq_idx] = 0
346
+ pix_Q = data[tuple(slice_list_Q)]
347
+ mask = np.abs(pix_Q) < (thres * q_rms)
348
+ pix_Q[mask] = 0
349
+ pix_I = data[tuple(slice_list_I)]
350
+ pix = pix_Q / pix_I
351
+ pix = remove_pixels_away_from_sun(pix, csys, 55)
352
+ elif stokes == "U/I":
353
+ if stokes_idx is None:
354
+ raise RuntimeError("The image does not have a Stokes axis.")
355
+ elif single_stokes_flag:
356
+ raise RuntimeError(
357
+ "The image is single stokes, but the Stokes parameter is not 'I'."
358
+ )
359
+ u_rms = estimate_rms_near_Sun(imagename, "U", rms_box)
360
+ slice_list_U = [slice(None)] * n_dims
361
+ slice_list_I = [slice(None)] * n_dims
362
+ slice_list_U[stokes_idx] = 2
363
+ slice_list_I[stokes_idx] = 0
364
+ if freq_idx is not None:
365
+ slice_list_U[freq_idx] = 0
366
+ slice_list_I[freq_idx] = 0
367
+ pix_U = data[tuple(slice_list_U)]
368
+ mask = np.abs(pix_U) < (thres * u_rms)
369
+ pix_U[mask] = 0
370
+ pix_I = data[tuple(slice_list_I)]
371
+ pix = pix_U / pix_I
372
+ pix = remove_pixels_away_from_sun(pix, csys, 55)
373
+ elif stokes == "U/V":
374
+ if stokes_idx is None:
375
+ raise RuntimeError("The image does not have a Stokes axis.")
376
+ elif single_stokes_flag:
377
+ raise RuntimeError(
378
+ "The image is single stokes, but the Stokes parameter is not 'I'."
379
+ )
380
+ u_rms = estimate_rms_near_Sun(imagename, "U", rms_box)
381
+ slice_list_U = [slice(None)] * n_dims
382
+ slice_list_V = [slice(None)] * n_dims
383
+ slice_list_U[stokes_idx] = 2
384
+ slice_list_V[stokes_idx] = 3
385
+ if freq_idx is not None:
386
+ slice_list_U[freq_idx] = 0
387
+ slice_list_V[freq_idx] = 0
388
+ pix_U = data[tuple(slice_list_U)]
389
+ pix_V = data[tuple(slice_list_V)]
390
+ mask = np.abs(pix_U) < (thres * u_rms)
391
+ pix_U[mask] = 0
392
+ pix = pix_U / pix_V
393
+ pix = remove_pixels_away_from_sun(pix, csys, 55)
394
+ elif stokes == "PANG":
395
+ if stokes_idx is None:
396
+ raise RuntimeError("The image does not have a Stokes axis.")
397
+ elif single_stokes_flag:
398
+ raise RuntimeError(
399
+ "The image is single stokes, but the Stokes parameter is not 'I'."
400
+ )
401
+ # Get Q and U data
402
+ slice_list_Q = [slice(None)] * n_dims
403
+ slice_list_U = [slice(None)] * n_dims
404
+ slice_list_Q[stokes_idx] = 1
405
+ slice_list_U[stokes_idx] = 2
406
+ slice_list_Q[freq_idx] = 0
407
+ slice_list_U[freq_idx] = 0
408
+ pix_Q = data[tuple(slice_list_Q)]
409
+ pix_U = data[tuple(slice_list_U)]
410
+
411
+ # Calculate polarized intensity for thresholding
412
+ p_intensity = np.sqrt(pix_Q**2 + pix_U**2)
413
+
414
+ # Estimate RMS for polarized intensity using L (linear polarization) estimation
415
+ # We use Q RMS as an approximation since we can't directly estimate L RMS
416
+ q_rms = estimate_rms_near_Sun(imagename, "Q", rms_box)
417
+ u_rms = estimate_rms_near_Sun(imagename, "U", rms_box)
418
+ p_rms = np.sqrt(q_rms**2 + u_rms**2)
419
+
420
+ # Calculate polarization angle: 0.5 * arctan2(U, Q) in degrees
421
+ pix = 0.5 * np.arctan2(pix_U, pix_Q) * 180 / np.pi
422
+
423
+ # Apply threshold mask - only show where polarized intensity is significant
424
+ mask = p_intensity < (thres * p_rms)
425
+ pix[mask] = np.nan
426
+
427
+ # Handle any infinite values by setting them to NaN
428
+ pix = np.where(np.isinf(pix), np.nan, pix)
429
+
430
+ # Remove pixels away from the Sun
431
+ pix = remove_pixels_away_from_sun(pix, csys, 55)
432
+
433
+ else:
434
+ slice_list_I = [slice(None)] * n_dims
435
+ slice_list_I[stokes_idx] = 0
436
+ slice_list_I[freq_idx] = 0
437
+ pix = data[tuple(slice_list_I)]
438
+
439
+ return pix, csys, psf
440
+
441
+
442
+ def get_image_metadata(imagename):
443
+ if not CASA_AVAILABLE:
444
+ if ASTROPY_AVAILABLE:
445
+ from astropy.coordinates import SkyCoord
446
+
447
+ ref_coord = SkyCoord(ra=180.0 * u.degree, dec=45.0 * u.degree)
448
+ ra_str = ref_coord.ra.to_string(unit=u.hour, sep=":", precision=2)
449
+ dec_str = ref_coord.dec.to_string(sep=":", precision=2)
450
+ ref_info = f"Reference: RA={ra_str}, Dec={dec_str}"
451
+ else:
452
+ ref_info = f"Reference: RA=180.000000°, Dec=45.000000°"
453
+ metadata = (
454
+ f"Image: {os.path.basename(imagename) if imagename else 'Demo Image'}\n"
455
+ f"Shape: (512, 512, 1, 1)\n"
456
+ f"Beam: 10.00 × 8.00 arcsec @ 45.0°\n"
457
+ f"{ref_info}\n"
458
+ f"Pixel scale: 3.600 × 3.600 arcsec\n"
459
+ f"Demo Mode: This is simulated data\n"
460
+ )
461
+ return metadata
462
+
463
+ ia_tool = IA()
464
+ ia_tool.open(imagename)
465
+ summary = ia_tool.summary(list=False, verbose=True)
466
+ metadata = ""
467
+ if "messages" in summary:
468
+ mds = summary["messages"]
469
+ for i, md in enumerate(mds, start=1):
470
+ clean_message = md.strip()
471
+ metadata += f"\n{clean_message}\n"
472
+ else:
473
+ metadata = "No metadata available"
474
+
475
+ """shape = ia_tool.shape()
476
+ csys = ia_tool.coordsys()
477
+
478
+ try:
479
+ beam = ia_tool.restoringbeam()
480
+ beam_info = (
481
+ f"Beam: {beam['major']['value']:.2f} × "
482
+ f"{beam['minor']['value']:.2f} arcsec @ "
483
+ f"{beam['positionangle']['value']:.1f}°"
484
+ )
485
+ except:
486
+ beam_info = "No beam information"
487
+ try:
488
+ ra_ref = csys.referencevalue()["numeric"][0] * 180 / np.pi
489
+ dec_ref = csys.referencevalue()["numeric"][1] * 180 / np.pi
490
+ if ASTROPY_AVAILABLE:
491
+ from astropy.coordinates import SkyCoord
492
+
493
+ ref_coord = SkyCoord(ra=ra_ref * u.degree, dec=dec_ref * u.degree)
494
+ ra_str = ref_coord.ra.to_string(unit=u.hour, sep=":", precision=2)
495
+ dec_str = ref_coord.dec.to_string(sep=":", precision=2)
496
+ coord_info = f"Reference: RA={ra_str}, Dec={dec_str}"
497
+ else:
498
+ coord_info = f"Reference: RA={ra_ref:.6f}°, Dec={dec_ref:.6f}°"
499
+ except:
500
+ coord_info = "No coordinate reference information"
501
+ try:
502
+ cdelt = csys.increment()["numeric"][0:2] * 180 / np.pi * 3600
503
+ pixel_scale = f"Pixel scale: {abs(cdelt[0]):.3f} × {abs(cdelt[1]):.3f} arcsec"
504
+ except:
505
+ pixel_scale = "No pixel scale information"
506
+ ia_tool.close()
507
+ metadata = (
508
+ f"Image: {os.path.basename(imagename)}\n"
509
+ f"Shape: {shape}\n"
510
+ f"{beam_info}\n"
511
+ f"{coord_info}\n"
512
+ f"{pixel_scale}\n"
513
+ )"""
514
+ return metadata
515
+
516
+
517
+ def twoD_gaussian(coords, amplitude, xo, yo, sigma_x, sigma_y, theta, offset):
518
+ x, y = coords
519
+ xo = float(xo)
520
+ yo = float(yo)
521
+ a = (np.cos(theta) ** 2) / (2 * sigma_x**2) + (np.sin(theta) ** 2) / (
522
+ 2 * sigma_y**2
523
+ )
524
+ b = -np.sin(2 * theta) / (4 * sigma_x**2) + np.sin(2 * theta) / (4 * sigma_y**2)
525
+ c = (np.sin(theta) ** 2) / (2 * sigma_x**2) + (np.cos(theta) ** 2) / (
526
+ 2 * sigma_y**2
527
+ )
528
+ g = offset + amplitude * np.exp(
529
+ -(a * ((x - xo) ** 2) + 2 * b * (x - xo) * (y - yo) + c * ((y - yo) ** 2))
530
+ )
531
+ return g.ravel()
532
+
533
+
534
+ def twoD_elliptical_ring(coords, amplitude, xo, yo, inner_r, outer_r, offset):
535
+ x, y = coords
536
+ dist2 = (x - xo) ** 2 + (y - yo) ** 2
537
+ inner2 = inner_r**2
538
+ outer2 = outer_r**2
539
+ vals = np.full_like(dist2, offset, dtype=float)
540
+ ring_mask = (dist2 >= inner2) & (dist2 <= outer2)
541
+ vals[ring_mask] = offset + amplitude
542
+ return vals.ravel()
543
+
544
+
545
+ def generate_tb_map(imagename, outfile=None, flux_data=None):
546
+ """
547
+ Generate brightness temperature map from flux-calibrated image.
548
+
549
+ Formula: TB = 1.222e6 * flux / freq^2 / (major * minor)
550
+ Where: freq in GHz, major/minor in arcsec
551
+
552
+ Parameters
553
+ ----------
554
+ imagename : str
555
+ Path to the input image (FITS or CASA format)
556
+ outfile : str, optional
557
+ Path for output FITS file. If None, returns data without saving.
558
+ flux_data : numpy.ndarray, optional
559
+ Pre-loaded flux data. If None, loads from imagename.
560
+
561
+ Returns
562
+ -------
563
+ tuple
564
+ (tb_data, header_info) where header_info contains beam and freq info
565
+ Returns (None, error_message) on failure
566
+ """
567
+ try:
568
+ from astropy.io import fits
569
+
570
+ is_fits = imagename.endswith('.fits') or imagename.endswith('.fts')
571
+
572
+ header_info = {}
573
+
574
+ if is_fits:
575
+ # FITS file
576
+ with fits.open(imagename) as hdul:
577
+ header = hdul[0].header
578
+ if flux_data is None:
579
+ flux_data = hdul[0].data
580
+
581
+ # Get beam major/minor (degrees -> arcsec)
582
+ if 'BMAJ' in header and 'BMIN' in header:
583
+ major = header['BMAJ'] * 3600
584
+ minor = header['BMIN'] * 3600
585
+ else:
586
+ return None, "Beam parameters (BMAJ/BMIN) not found in header"
587
+
588
+ # Get frequency (Hz -> GHz)
589
+ freq_hz = None
590
+ for key in ['CRVAL3', 'CRVAL4', 'FREQ', 'RESTFRQ']:
591
+ if key in header and header[key] is not None:
592
+ try:
593
+ val = float(header[key])
594
+ if val > 1e6: # Must be Hz
595
+ freq_hz = val
596
+ break
597
+ except:
598
+ pass
599
+
600
+ if freq_hz is None:
601
+ return None, "Frequency not found in header"
602
+
603
+ freq_ghz = freq_hz / 1e9
604
+
605
+ header_info = {
606
+ 'major': major,
607
+ 'minor': minor,
608
+ 'freq_ghz': freq_ghz,
609
+ 'original_header': header.copy()
610
+ }
611
+ else:
612
+ # CASA image
613
+ if not CASA_AVAILABLE:
614
+ return None, "CASA tools not available for CASA image"
615
+
616
+ ia = IA()
617
+ ia.open(imagename)
618
+
619
+ if flux_data is None:
620
+ flux_data = ia.getchunk()
621
+ # Squeeze to 2D for display (keep original for full Stokes save)
622
+ if flux_data.ndim == 4:
623
+ flux_data = flux_data[:, :, 0, 0] # Take first Stokes and freq
624
+ elif flux_data.ndim == 3:
625
+ flux_data = flux_data[:, :, 0] # Take first plane
626
+
627
+ # Get beam info
628
+ beam = ia.restoringbeam()
629
+ if beam and 'major' in beam:
630
+ major = beam['major']['value']
631
+ minor = beam['minor']['value']
632
+ if beam['major']['unit'] == 'arcsec':
633
+ pass # already in arcsec
634
+ elif beam['major']['unit'] == 'deg':
635
+ major *= 3600
636
+ minor *= 3600
637
+ else:
638
+ ia.close()
639
+ return None, "Beam parameters not found in CASA image"
640
+
641
+ # Get frequency
642
+ csys = ia.coordsys()
643
+ units = csys.units()
644
+ refval = csys.referencevalue()['numeric']
645
+
646
+ freq_hz = None
647
+ for i, unit in enumerate(units):
648
+ if unit == 'Hz':
649
+ freq_hz = refval[i]
650
+ break
651
+
652
+ ia.close()
653
+
654
+ if freq_hz is None:
655
+ return None, "Frequency not found in CASA image"
656
+
657
+ freq_ghz = freq_hz / 1e9
658
+
659
+ header_info = {
660
+ 'major': major,
661
+ 'minor': minor,
662
+ 'freq_ghz': freq_ghz
663
+ }
664
+
665
+ # Calculate brightness temperature
666
+ # print(f"[TB] Beam: {header_info['major']:.2f}\" x {header_info['minor']:.2f}\", Freq: {header_info['freq_ghz']:.4f} GHz")
667
+ tb_data = 1.222e6 * flux_data / (freq_ghz**2) / (major * minor)
668
+
669
+ # print(f"[TB] Temperature range: {np.nanmin(tb_data):.2e} to {np.nanmax(tb_data):.2e} K")
670
+
671
+ # Save to file if outfile specified
672
+ if outfile is not None:
673
+ if is_fits:
674
+ new_header = header_info['original_header'].copy()
675
+ new_header['BUNIT'] = 'K'
676
+ new_header['HISTORY'] = 'Converted to brightness temperature by SolarViewer'
677
+
678
+ # Ensure RESTFRQ is present (needed for downstream HPC conversion)
679
+ if 'RESTFRQ' not in new_header:
680
+ freq_hz = header_info['freq_ghz'] * 1e9
681
+ new_header['RESTFRQ'] = freq_hz
682
+
683
+ # Get original data to check for full Stokes
684
+ original_data = fits.getdata(imagename)
685
+
686
+ # Check if original is multi-Stokes (3D or 4D with Stokes axis)
687
+ if original_data.ndim >= 3:
688
+ # Find number of Stokes planes
689
+ stokes_idx = None
690
+ for i in range(1, header_info['original_header'].get('NAXIS', 0) + 1):
691
+ if header_info['original_header'].get(f'CTYPE{i}', '').upper() == 'STOKES':
692
+ stokes_idx = i - 1 # 0-indexed for numpy
693
+ break
694
+
695
+ if stokes_idx is not None:
696
+ # Full Stokes - convert all planes
697
+ # print(f"[TB] Converting full Stokes data (shape: {original_data.shape})")
698
+ tb_data_save = 1.222e6 * original_data / (freq_ghz**2) / (major * minor)
699
+ else:
700
+ # 3D but not Stokes - transpose as needed
701
+ if original_data.shape != tb_data.shape:
702
+ tb_data_save = tb_data.T
703
+ else:
704
+ tb_data_save = tb_data
705
+ else:
706
+ # 2D data
707
+ if original_data.shape != tb_data.shape:
708
+ tb_data_save = tb_data.T
709
+ else:
710
+ tb_data_save = tb_data
711
+
712
+ new_hdu = fits.PrimaryHDU(data=tb_data_save, header=new_header)
713
+ new_hdu.writeto(outfile, overwrite=True)
714
+ else:
715
+ # For CASA, need to export first
716
+ temp_export = outfile + '.temp_export.fits'
717
+ ia = IA()
718
+ ia.open(imagename)
719
+ ia.tofits(temp_export, overwrite=True, stokeslast=False)
720
+ ia.close()
721
+
722
+ with fits.open(temp_export) as hdul:
723
+ original_data = hdul[0].data
724
+ new_header = hdul[0].header.copy()
725
+ new_header['BUNIT'] = 'K'
726
+ new_header['HISTORY'] = 'Converted to brightness temperature by SolarViewer'
727
+
728
+ # Check for multi-Stokes
729
+ if original_data.ndim >= 3:
730
+ # Full Stokes - convert all planes
731
+ # print(f"[TB] Converting full Stokes CASA data (shape: {original_data.shape})")
732
+ tb_data_save = 1.222e6 * original_data / (freq_ghz**2) / (major * minor)
733
+ else:
734
+ if original_data.shape != tb_data.shape:
735
+ tb_data_save = tb_data.T
736
+ else:
737
+ tb_data_save = tb_data
738
+ new_hdu = fits.PrimaryHDU(data=tb_data_save, header=new_header)
739
+ new_hdu.writeto(outfile, overwrite=True)
740
+
741
+ if os.path.exists(temp_export):
742
+ os.remove(temp_export)
743
+
744
+ # print(f"[TB] Saved TB map to: {outfile}")
745
+
746
+ return tb_data, header_info
747
+
748
+ except Exception as e:
749
+ import traceback
750
+ traceback.print_exc()
751
+ return None, str(e)
752
+
753
+
754
+ def generate_flux_map(imagename, outfile=None, tb_data=None):
755
+ """
756
+ Generate flux map from brightness temperature image.
757
+
758
+ Reverse formula: flux = TB * freq^2 * (major * minor) / 1.222e6
759
+ Where: freq in GHz, major/minor in arcsec
760
+
761
+ Parameters
762
+ ----------
763
+ imagename : str
764
+ Path to the input TB image (FITS or CASA format)
765
+ outfile : str, optional
766
+ Path for output FITS file. If None, returns data without saving.
767
+ tb_data : numpy.ndarray, optional
768
+ Pre-loaded TB data. If None, loads from imagename.
769
+
770
+ Returns
771
+ -------
772
+ tuple
773
+ (flux_data, header_info) where header_info contains beam and freq info
774
+ Returns (None, error_message) on failure
775
+ """
776
+ try:
777
+ from astropy.io import fits
778
+
779
+ is_fits = imagename.endswith('.fits') or imagename.endswith('.fts')
780
+
781
+ header_info = {}
782
+
783
+ if is_fits:
784
+ # FITS file
785
+ with fits.open(imagename) as hdul:
786
+ header = hdul[0].header
787
+ if tb_data is None:
788
+ tb_data = hdul[0].data
789
+
790
+ # Get beam major/minor (degrees -> arcsec)
791
+ if 'BMAJ' in header and 'BMIN' in header:
792
+ major = header['BMAJ'] * 3600
793
+ minor = header['BMIN'] * 3600
794
+ else:
795
+ return None, "Beam parameters (BMAJ/BMIN) not found in header"
796
+
797
+ # Get frequency (Hz -> GHz)
798
+ freq_hz = None
799
+ for key in ['CRVAL3', 'CRVAL4', 'FREQ', 'RESTFRQ']:
800
+ if key in header and header[key] is not None:
801
+ try:
802
+ val = float(header[key])
803
+ if val > 1e6: # Must be Hz
804
+ freq_hz = val
805
+ break
806
+ except:
807
+ pass
808
+
809
+ if freq_hz is None:
810
+ return None, "Frequency not found in header"
811
+
812
+ freq_ghz = freq_hz / 1e9
813
+
814
+ header_info = {
815
+ 'major': major,
816
+ 'minor': minor,
817
+ 'freq_ghz': freq_ghz,
818
+ 'original_header': header.copy()
819
+ }
820
+ else:
821
+ # CASA image
822
+ if not CASA_AVAILABLE:
823
+ return None, "CASA tools not available for CASA image"
824
+
825
+ ia = IA()
826
+ ia.open(imagename)
827
+
828
+ if tb_data is None:
829
+ tb_data = ia.getchunk()
830
+ while tb_data.ndim > 2:
831
+ tb_data = tb_data[:, :, 0] if tb_data.shape[2] == 1 else tb_data[:, :, 0, 0]
832
+
833
+ # Get beam info
834
+ beam = ia.restoringbeam()
835
+ if beam and 'major' in beam:
836
+ major = beam['major']['value']
837
+ minor = beam['minor']['value']
838
+ if beam['major']['unit'] == 'arcsec':
839
+ pass
840
+ elif beam['major']['unit'] == 'deg':
841
+ major *= 3600
842
+ minor *= 3600
843
+ else:
844
+ ia.close()
845
+ return None, "Beam parameters not found in CASA image"
846
+
847
+ # Get frequency
848
+ csys = ia.coordsys()
849
+ units = csys.units()
850
+ refval = csys.referencevalue()['numeric']
851
+
852
+ freq_hz = None
853
+ for i, unit in enumerate(units):
854
+ if unit == 'Hz':
855
+ freq_hz = refval[i]
856
+ break
857
+
858
+ ia.close()
859
+
860
+ if freq_hz is None:
861
+ return None, "Frequency not found in CASA image"
862
+
863
+ freq_ghz = freq_hz / 1e9
864
+
865
+ header_info = {
866
+ 'major': major,
867
+ 'minor': minor,
868
+ 'freq_ghz': freq_ghz
869
+ }
870
+
871
+ # Calculate flux: flux = TB * freq^2 * (major * minor) / 1.222e6
872
+ # print(f"[Flux] Beam: {header_info['major']:.2f}\" x {header_info['minor']:.2f}\", Freq: {header_info['freq_ghz']:.4f} GHz")
873
+ flux_data = tb_data * (freq_ghz**2) * (major * minor) / 1.222e6
874
+
875
+ # print(f"[Flux] Flux range: {np.nanmin(flux_data):.2e} to {np.nanmax(flux_data):.2e} Jy/beam")
876
+
877
+ # Save to file if outfile specified
878
+ if outfile is not None:
879
+ if is_fits:
880
+ new_header = header_info['original_header'].copy()
881
+ new_header['BUNIT'] = 'Jy/beam'
882
+ new_header['HISTORY'] = 'Converted from brightness temperature by SolarViewer'
883
+
884
+ # Ensure RESTFRQ is present (needed for downstream HPC conversion)
885
+ if 'RESTFRQ' not in new_header:
886
+ freq_hz = header_info['freq_ghz'] * 1e9
887
+ new_header['RESTFRQ'] = freq_hz
888
+
889
+ # Get original data to check shape
890
+ original_data = fits.getdata(imagename)
891
+
892
+ # Handle multi-Stokes
893
+ if original_data.ndim >= 3:
894
+ stokes_idx = None
895
+ for i in range(1, header_info['original_header'].get('NAXIS', 0) + 1):
896
+ if header_info['original_header'].get(f'CTYPE{i}', '').upper() == 'STOKES':
897
+ stokes_idx = i - 1
898
+ break
899
+
900
+ if stokes_idx is not None:
901
+ # print(f"[Flux] Converting full Stokes data (shape: {original_data.shape})")
902
+ flux_data_save = original_data * (freq_ghz**2) * (major * minor) / 1.222e6
903
+ else:
904
+ if original_data.shape != flux_data.shape:
905
+ flux_data_save = flux_data.T
906
+ else:
907
+ flux_data_save = flux_data
908
+ else:
909
+ if original_data.shape != flux_data.shape:
910
+ flux_data_save = flux_data.T
911
+ else:
912
+ flux_data_save = flux_data
913
+
914
+ new_hdu = fits.PrimaryHDU(data=flux_data_save, header=new_header)
915
+ new_hdu.writeto(outfile, overwrite=True)
916
+ else:
917
+ # For CASA, need to export first
918
+ temp_export = outfile + '.temp_export.fits'
919
+ ia = IA()
920
+ ia.open(imagename)
921
+ ia.tofits(temp_export, overwrite=True, stokeslast=False)
922
+ ia.close()
923
+
924
+ with fits.open(temp_export) as hdul:
925
+ original_data = hdul[0].data
926
+ new_header = hdul[0].header.copy()
927
+ new_header['BUNIT'] = 'Jy/beam'
928
+ new_header['HISTORY'] = 'Converted from brightness temperature by SolarViewer'
929
+
930
+ if original_data.ndim >= 3:
931
+ # print(f"[Flux] Converting full Stokes CASA data (shape: {original_data.shape})")
932
+ flux_data_save = original_data * (freq_ghz**2) * (major * minor) / 1.222e6
933
+ else:
934
+ if original_data.shape != flux_data.shape:
935
+ flux_data_save = flux_data.T
936
+ else:
937
+ flux_data_save = flux_data
938
+
939
+ new_hdu = fits.PrimaryHDU(data=flux_data_save, header=new_header)
940
+ new_hdu.writeto(outfile, overwrite=True)
941
+
942
+ if os.path.exists(temp_export):
943
+ os.remove(temp_export)
944
+
945
+ # print(f"[Flux] Saved flux map to: {outfile}")
946
+
947
+ return flux_data, header_info
948
+
949
+ except Exception as e:
950
+ import traceback
951
+ traceback.print_exc()
952
+ return None, str(e)