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,1916 @@
1
+ # requires sunpy
2
+ import os
3
+ import sys
4
+
5
+ # Only set QT_QPA_PLATFORM to offscreen when running this script directly
6
+ # This prevents issues when importing this module from other scripts
7
+ if __name__ == "__main__":
8
+ os.environ["QT_QPA_PLATFORM"] = "offscreen"
9
+
10
+ import numpy as np, matplotlib.pyplot as plt, astropy.units as u, matplotlib.patches as patches
11
+ from astropy.coordinates import SkyCoord, EarthLocation
12
+ from astropy.time import Time
13
+ from astropy.io import fits
14
+ from datetime import timedelta
15
+ from matplotlib import rcParams
16
+ from casatools import image as IA
17
+ from casatasks import immath, exportfits
18
+ import math
19
+
20
+ try:
21
+ from sunpy.net import Fido, attrs as a
22
+ import sunpy.map
23
+ from sunpy.coordinates import Helioprojective
24
+ from sunpy.coordinates.sun import P
25
+
26
+ sunpy_imported = True
27
+ except ImportError:
28
+ print("[ERROR] sunpy is not installed or some components are missing")
29
+ sunpy_imported = False
30
+
31
+ from .utils import estimate_rms_near_Sun, remove_pixels_away_from_sun
32
+
33
+
34
+ rcParams["axes.linewidth"] = 1.4
35
+ rcParams["font.size"] = 12
36
+
37
+
38
+ def get_Earthlocation(fits_file="", lat=None, long=None, height=None, observatory=None):
39
+ hdul = fits.open(fits_file)
40
+ header = hdul[0].header
41
+ POS = None
42
+ if observatory is not None:
43
+ if observatory.upper() == "LOFAR":
44
+ lat = "52:55:53.90"
45
+ long = "06:51:56.95"
46
+ height = 50.16
47
+ elif observatory.upper() == "MWA":
48
+ lat = "-26:42:12.09"
49
+ long = "116:40:14.84"
50
+ height = 375.75
51
+ elif observatory.upper() == "MEERKAT":
52
+ lat = "-30:42:47.36"
53
+ long = "21:26:38.09"
54
+ height = 1050.82
55
+ elif observatory.upper() == "GMRT" or observatory.upper() == "UGMRT":
56
+ lat = "19:05:26.21"
57
+ long = "74:02:59.90"
58
+ height = 639.68
59
+ else:
60
+ print(
61
+ "[WARNING] Observatory parameter could not be understood. Trying from lat and long...."
62
+ )
63
+ if lat is not None and long is not None and height is not None:
64
+ #print(
65
+ # f"[DEBUG] USING observer position from arguments LAT={lat}, LON={long}, HEIGHT={height}"
66
+ #)
67
+ POS = EarthLocation.from_geodetic(lon=long, lat=lat, height=height)
68
+
69
+ if lat is None or long is None or height is None:
70
+ try:
71
+ #print(f"No lat, long input found. Trying to read from header .....")
72
+ x = header["OBSGEO-X"]
73
+ y = header["OBSGEO-Y"]
74
+ z = header["OBSGEO-Z"]
75
+ if abs(x) > 1e6 and abs(y) > 1e6 and abs(z) > 1e6:
76
+ POS = EarthLocation.from_geocentric(x, y, z, unit=u.m)
77
+ else:
78
+ print(
79
+ f"[WARNING] Invalid telescope position in header. Looking for observatory from header..."
80
+ )
81
+ ia_tool = IA()
82
+ ia_tool.open(fits_file)
83
+ metadata_dict = ia_tool.summary(list=False, verbose=True)
84
+ lat, long, height, observatory = extract_telescope_position(
85
+ metadata_dict
86
+ )
87
+ ia_tool.close()
88
+ POS = EarthLocation.from_geodetic(lon=long, lat=lat, height=height)
89
+ except Exception as e:
90
+ print(f"[ERROR] {str(e)}")
91
+ return POS
92
+
93
+
94
+ def convert_to_hpc(
95
+ fits_file,
96
+ Stokes="I",
97
+ thres=10,
98
+ lat=None,
99
+ long=None,
100
+ height=None,
101
+ observatory=None,
102
+ rms_box=(0, 200, 0, 130),
103
+ ):
104
+ """
105
+ Convert a FITS file to helioprojective coordinates
106
+ TODO: Redundantly written for ndim>2 and ndim=2, should be refactored
107
+ """
108
+ if not sunpy_imported:
109
+ print("[ERROR] Cannot convert to helioprojective coordinates without sunpy")
110
+ return None, None, None
111
+
112
+ single_stokes_flag = False
113
+ try:
114
+ ia_tool = IA()
115
+ ia_tool.open(fits_file)
116
+ psf = ia_tool.restoringbeam()
117
+ csys = ia_tool.coordsys()
118
+ ia_tool.close()
119
+ # csys = "hpc"
120
+ except Exception as e:
121
+ raise RuntimeError(f"Failed to open image {fits_file}: {e}")
122
+
123
+ #print("************************")
124
+ #print(f"[INFO] Starting hpc conversion for image {fits_file}")
125
+ # Read the FITS file
126
+ hdu = fits.open(fits_file)
127
+ header = hdu[0].header
128
+ if header["SIMPLE"] == False:
129
+ print("[ERROR] FITS file is not a valid image")
130
+ return None, None, None
131
+ ndim = header["NAXIS"]
132
+ if ndim < 2:
133
+ single_stokes_flag = True
134
+
135
+ if ndim > 2:
136
+ stokes_map = {"I": 0, "Q": 1, "U": 2, "V": 3}
137
+ try:
138
+ # Create a mapping of axis types to their positions
139
+ axis_types = {f"CTYPE{i+1}": header[f"CTYPE{i+1}"] for i in range(ndim)}
140
+ stokes_axis = None
141
+ freq_axis = None
142
+ spatial_axes = []
143
+
144
+ # Find the axes by their types
145
+ for axis_num, axis_type in axis_types.items():
146
+ axis_index = int(axis_num[-1]) - 1 # Convert to 0-based index
147
+ if axis_type == "STOKES":
148
+ stokes_axis = axis_index
149
+ elif axis_type == "FREQ":
150
+ freq_axis = axis_index
151
+ elif axis_type in ["RA---SIN", "DEC--SIN", "HPLN-TAN", "HPLT-TAN"]:
152
+ spatial_axes.append(axis_index)
153
+
154
+ # Account for NumPy/FITS axis reversal
155
+ # In FITS: (RA, DEC, FREQ, STOKES) -> In NumPy: (STOKES, FREQ, DEC, RA)
156
+ numpy_axis_map = {}
157
+ if stokes_axis is not None:
158
+ numpy_axis_map[stokes_axis] = ndim - stokes_axis - 1
159
+ if freq_axis is not None:
160
+ numpy_axis_map[freq_axis] = ndim - freq_axis - 1
161
+ for spatial_axis in spatial_axes:
162
+ numpy_axis_map[spatial_axis] = ndim - spatial_axis - 1
163
+
164
+ #print(f"[DEBUG] FITS axes mapping to NumPy positions: {numpy_axis_map}")
165
+ #print(f"[DEBUG] Spatial axes in FITS (0-indexed): {spatial_axes}")
166
+
167
+ # Update the axes to their NumPy positions
168
+ if stokes_axis is not None:
169
+ original_stokes_axis = (
170
+ stokes_axis + 1
171
+ ) # Save the original FITS axis number (1-indexed)
172
+ stokes_axis = numpy_axis_map[stokes_axis]
173
+ if freq_axis is not None:
174
+ original_freq_axis = (
175
+ freq_axis + 1
176
+ ) # Save the original FITS axis number (1-indexed)
177
+ freq_axis = numpy_axis_map[freq_axis]
178
+
179
+ if stokes_axis is not None and header[f"NAXIS{original_stokes_axis}"] == 1:
180
+ single_stokes_flag = True
181
+
182
+ data_all = hdu[0].data
183
+ #print(f"[DEBUG] Data from FITS file: {data_all.shape}")
184
+ if Stokes in ["I", "Q", "U", "V"]:
185
+ idx = stokes_map.get(Stokes)
186
+ if idx is None:
187
+ raise ValueError(f"Unknown Stokes parameter: {Stokes}")
188
+ slice_list = [slice(None)] * ndim
189
+ if stokes_axis is not None:
190
+ if single_stokes_flag:
191
+ if Stokes != "I":
192
+ raise RuntimeError(
193
+ "The image is single stokes, but the Stokes parameter is not 'I'."
194
+ )
195
+ slice_list[stokes_axis] = idx
196
+ #print(
197
+ #f"[DEBUG] Stokes axis in FITS: {original_stokes_axis} (NumPy position: {stokes_axis})"
198
+ #)
199
+ if freq_axis is not None:
200
+ #print(
201
+ #f"[DEBUG] Frequency axis in FITS: {original_freq_axis} (NumPy position: {freq_axis})"
202
+ #)
203
+ pass
204
+ if freq_axis is not None:
205
+ slice_list[freq_axis] = 0
206
+ data = data_all[tuple(slice_list)]
207
+ elif Stokes == "L":
208
+ if stokes_axis is None:
209
+ raise RuntimeError("The image does not have a Stokes axis.")
210
+ elif single_stokes_flag:
211
+ raise RuntimeError(
212
+ "The image is single stokes, but the Stokes parameter is not 'I'."
213
+ )
214
+ slice_list_Q = [slice(None)] * ndim
215
+ slice_list_U = [slice(None)] * ndim
216
+ slice_list_Q[stokes_axis] = 1
217
+ slice_list_U[stokes_axis] = 2
218
+ slice_list_Q[freq_axis] = 0
219
+ slice_list_U[freq_axis] = 0
220
+ pix_Q = data_all[tuple(slice_list_Q)]
221
+ pix_U = data_all[tuple(slice_list_U)]
222
+ data = np.sqrt(pix_Q**2 + pix_U**2)
223
+ elif Stokes == "Lfrac":
224
+ if stokes_axis is None:
225
+ raise RuntimeError("The image does not have a Stokes axis.")
226
+ elif single_stokes_flag:
227
+ raise RuntimeError(
228
+ "The image is single stokes, but the Stokes parameter is not 'I'."
229
+ )
230
+ try:
231
+ immath(imagename=fits_file, outfile="temp_p_map.im", mode="lpoli")
232
+ p_rms = estimate_rms_near_Sun("temp_p_map.im", "I", rms_box)
233
+ except Exception as e:
234
+ raise RuntimeError(f"Error generating polarization map: {e}")
235
+ finally:
236
+ os.system("rm -rf temp_p_map.im")
237
+ slice_list_Q = [slice(None)] * ndim
238
+ slice_list_U = [slice(None)] * ndim
239
+ slice_list_I = [slice(None)] * ndim
240
+ slice_list_Q[stokes_axis] = 1
241
+ slice_list_U[stokes_axis] = 2
242
+ slice_list_I[stokes_axis] = 0
243
+ slice_list_Q[freq_axis] = 0
244
+ slice_list_U[freq_axis] = 0
245
+ slice_list_I[freq_axis] = 0
246
+ pix_Q = data_all[tuple(slice_list_Q)]
247
+ pix_U = data_all[tuple(slice_list_U)]
248
+ pix_I = data_all[tuple(slice_list_I)]
249
+ L = np.sqrt(pix_Q**2 + pix_U**2)
250
+ mask = L < (thres * p_rms)
251
+ L[mask] = 0
252
+ Lfrac = L / pix_I
253
+ data = remove_pixels_away_from_sun(Lfrac, csys, 60)
254
+ elif Stokes == "Vfrac":
255
+ if stokes_axis is None:
256
+ raise RuntimeError("The image does not have a Stokes axis.")
257
+ elif single_stokes_flag:
258
+ raise RuntimeError(
259
+ "The image is single stokes, but the Stokes parameter is not 'I'."
260
+ )
261
+ slice_list_V = [slice(None)] * ndim
262
+ slice_list_I = [slice(None)] * ndim
263
+ slice_list_V[stokes_axis] = 3
264
+ slice_list_I[stokes_axis] = 0
265
+ slice_list_V[freq_axis] = 0
266
+ slice_list_I[freq_axis] = 0
267
+ pix_V = data_all[tuple(slice_list_V)]
268
+ pix_I = data_all[tuple(slice_list_I)]
269
+ v_rms = estimate_rms_near_Sun(fits_file, "V", rms_box)
270
+ mask = np.abs(pix_V) < (thres * v_rms)
271
+ pix_V[mask] = 0
272
+ Vfrac = pix_V / pix_I
273
+ data = remove_pixels_away_from_sun(Vfrac, csys, 60)
274
+ elif Stokes == "Q/I":
275
+ if stokes_axis is None:
276
+ raise RuntimeError("The image does not have a Stokes axis.")
277
+ elif single_stokes_flag:
278
+ raise RuntimeError(
279
+ "The image is single stokes, but the Stokes parameter is not 'I'."
280
+ )
281
+ q_rms = estimate_rms_near_Sun(fits_file, "Q", rms_box)
282
+ slice_list_Q = [slice(None)] * ndim
283
+ slice_list_I = [slice(None)] * ndim
284
+ slice_list_Q[stokes_axis] = 1
285
+ slice_list_I[stokes_axis] = 0
286
+ slice_list_Q[freq_axis] = 0
287
+ slice_list_I[freq_axis] = 0
288
+ pix_Q = data_all[tuple(slice_list_Q)]
289
+ mask = np.abs(pix_Q) < (thres * q_rms)
290
+ pix_Q[mask] = 0
291
+ pix_I = data_all[tuple(slice_list_I)]
292
+ Q_I = pix_Q / pix_I
293
+ data = remove_pixels_away_from_sun(Q_I, csys, 60)
294
+ elif Stokes == "U/I":
295
+ if stokes_axis is None:
296
+ raise RuntimeError("The image does not have a Stokes axis.")
297
+ elif single_stokes_flag:
298
+ raise RuntimeError(
299
+ "The image is single stokes, but the Stokes parameter is not 'I'."
300
+ )
301
+ u_rms = estimate_rms_near_Sun(fits_file, "U", rms_box)
302
+ slice_list_U = [slice(None)] * ndim
303
+ slice_list_I = [slice(None)] * ndim
304
+ slice_list_U[stokes_axis] = 2
305
+ slice_list_I[stokes_axis] = 0
306
+ slice_list_U[freq_axis] = 0
307
+ slice_list_I[freq_axis] = 0
308
+ pix_U = data_all[tuple(slice_list_U)]
309
+ mask = np.abs(pix_U) < (thres * u_rms)
310
+ pix_U[mask] = 0
311
+ pix_I = data_all[tuple(slice_list_I)]
312
+ U_I = pix_U / pix_I
313
+ data = remove_pixels_away_from_sun(U_I, csys, 60)
314
+ elif Stokes == "U/V":
315
+ if stokes_axis is None:
316
+ raise RuntimeError("The image does not have a Stokes axis.")
317
+ elif single_stokes_flag:
318
+ raise RuntimeError(
319
+ "The image is single stokes, but the Stokes parameter is not 'I'."
320
+ )
321
+ u_rms = estimate_rms_near_Sun(fits_file, "U", rms_box)
322
+ slice_list_U = [slice(None)] * ndim
323
+ slice_list_V = [slice(None)] * ndim
324
+ slice_list_U[stokes_axis] = 2
325
+ slice_list_V[stokes_axis] = 3
326
+ slice_list_U[freq_axis] = 0
327
+ slice_list_V[freq_axis] = 0
328
+ pix_U = data_all[tuple(slice_list_U)]
329
+ pix_V = data_all[tuple(slice_list_V)]
330
+ mask = np.abs(pix_U) < (thres * u_rms)
331
+ pix_U[mask] = 0
332
+ U_V = pix_U / pix_V
333
+ data = remove_pixels_away_from_sun(U_V, csys, 60)
334
+ elif Stokes == "PANG":
335
+ if stokes_axis is None:
336
+ raise RuntimeError("The image does not have a Stokes axis.")
337
+ elif single_stokes_flag:
338
+ raise RuntimeError(
339
+ "The image is single stokes, but the Stokes parameter is not 'I'."
340
+ )
341
+ slice_list_Q = [slice(None)] * ndim
342
+ slice_list_U = [slice(None)] * ndim
343
+ slice_list_Q[stokes_axis] = 1
344
+ slice_list_U[stokes_axis] = 2
345
+ slice_list_Q[freq_axis] = 0
346
+ slice_list_U[freq_axis] = 0
347
+ pix_Q = data_all[tuple(slice_list_Q)]
348
+ pix_U = data_all[tuple(slice_list_U)]
349
+ PANG = 0.5 * np.arctan2(pix_U, pix_Q) * 180 / np.pi
350
+ data = PANG
351
+ else:
352
+ slice_list_I = [slice(None)] * ndim
353
+ slice_list_I[stokes_axis] = 0
354
+ slice_list_I[freq_axis] = 0
355
+ data = data_all[tuple(slice_list_I)]
356
+
357
+ except Exception as e:
358
+ #print(f"[ERROR] {str(e)}")
359
+ return None, None, None
360
+
361
+ # Get frequency from the appropriate header keyword based on the original FITS axis
362
+ if freq_axis is not None:
363
+ frequency = header[f"CRVAL{original_freq_axis}"] * u.Hz
364
+ #print(f"Using frequency from CRVAL{original_freq_axis}: {frequency}")
365
+ else:
366
+ # Try to find frequency from other header keywords if freq_axis is not identified
367
+ if "FREQ" in header:
368
+ frequency = header["FREQ"] * u.Hz
369
+ #print(f"Using frequency from FREQ keyword: {frequency}")
370
+ else:
371
+ raise RuntimeError("Could not determine frequency from header")
372
+ obstime = Time(header["date-obs"])
373
+ ia_tool = IA()
374
+ ia_tool.open(fits_file)
375
+ metadata_dict = ia_tool.summary(list=False, verbose=True)
376
+ lat, long, height, observatory = extract_telescope_position(metadata_dict)
377
+ ia_tool.close()
378
+ if (
379
+ observatory.upper() == "LOFAR"
380
+ or observatory.upper() == "MWA"
381
+ or observatory.upper() == "MEERKAT"
382
+ or observatory.upper() == "GMRT"
383
+ or observatory.upper() == "UGMRT"
384
+ ):
385
+ #print(f"Observatory: {observatory}")
386
+ pass
387
+ else:
388
+ observatory = None
389
+ POS = get_Earthlocation(fits_file=fits_file, observatory=observatory)
390
+ gcrs = SkyCoord(POS.get_gcrs(obstime))
391
+ reference_coord = SkyCoord(
392
+ header["CRVAL1"] * u.Unit(header["CUNIT1"]),
393
+ header["CRVAL2"] * u.Unit(header["CUNIT2"]),
394
+ frame="gcrs",
395
+ obstime=obstime,
396
+ obsgeoloc=gcrs.cartesian,
397
+ obsgeovel=gcrs.velocity.to_cartesian(),
398
+ distance=gcrs.hcrs.distance,
399
+ )
400
+ reference_coord_arcsec = reference_coord.transform_to(
401
+ Helioprojective(observer=gcrs)
402
+ )
403
+ cdelta_1 = (np.abs(header["CDELT1"]) * u.deg).to(u.arcsec)
404
+ cdelta_2 = (np.abs(header["CDELT2"]) * u.deg).to(u.arcsec)
405
+ P_angle = P(obstime) * -1
406
+ #print(f"Rotating by {P_angle}")
407
+
408
+ # Ensure frequency unit is properly formatted for the FITS header
409
+ new_header = sunpy.map.make_fitswcs_header(
410
+ data,
411
+ reference_coord_arcsec,
412
+ reference_pixel=u.Quantity(
413
+ [header["CRPIX1"] - 1, header["CRPIX2"] - 1] * u.pix,
414
+ ),
415
+ scale=u.Quantity([cdelta_1, cdelta_2] * u.arcsec / u.pix),
416
+ rotation_angle=P_angle,
417
+ wavelength=frequency.to(u.MHz).round(2),
418
+ observatory=observatory,
419
+ )
420
+
421
+ # Add additional metadata to the header
422
+ new_header["DATE-OBS"] = header.get("DATE-OBS", obstime.isot)
423
+ new_header["TELESCOP"] = observatory
424
+ new_header["INSTRUME"] = header.get("INSTRUME", "Unknown")
425
+ new_header["OBJECT"] = "Sun"
426
+ new_header["ORIGIN"] = "Solar Radio Image Viewer"
427
+
428
+ # Add frequency information
429
+ new_header["FREQ"] = frequency.to(u.Hz).value
430
+ new_header["FREQUNIT"] = "Hz"
431
+
432
+ # Add coordinate system information
433
+ new_header["WCSNAME"] = "Helioprojective"
434
+ new_header["CTYPE1"] = (
435
+ "HPLN-TAN" # Helioprojective longitude with TAN projection
436
+ )
437
+ new_header["CTYPE2"] = (
438
+ "HPLT-TAN" # Helioprojective latitude with TAN projection
439
+ )
440
+ new_header["CUNIT1"] = "arcsec"
441
+ new_header["CUNIT2"] = "arcsec"
442
+
443
+ # Add original processing information if available
444
+ if "HISTORY" in new_header:
445
+ for history in header["HISTORY"]:
446
+ new_header.append(history)
447
+
448
+ # Add new history entry
449
+ new_history = f"Converted to helioprojective coordinates on {Time.now().isot}"
450
+ if "HISTORY" in new_header:
451
+ if isinstance(new_header["HISTORY"], list):
452
+ new_header["HISTORY"].append(new_history)
453
+ else:
454
+ new_header["HISTORY"] = [new_header["HISTORY"], new_history]
455
+ else:
456
+ new_header["HISTORY"] = new_history
457
+
458
+ # Add information about the Stokes parameter
459
+ new_header["STOKES"] = Stokes
460
+
461
+ # Add beam information if available (adjust BPA by P-angle rotation)
462
+ if psf:
463
+ new_header["BMAJ"] = header["BMAJ"]
464
+ new_header["BMIN"] = header["BMIN"]
465
+ # Adjust beam position angle by the P-angle rotation
466
+ original_bpa = header.get("BPA", 0)
467
+ rotated_bpa = original_bpa + P_angle.to(u.deg).value
468
+ new_header["BPA"] = rotated_bpa
469
+
470
+ # Ensure the wavelength unit is properly formatted
471
+ if "waveunit" in new_header:
472
+ waveunit = new_header["waveunit"].strip().lower()
473
+ if waveunit == "mhz":
474
+ new_header["waveunit"] = "MHz"
475
+ elif waveunit == "ghz":
476
+ new_header["waveunit"] = "GHz"
477
+ elif waveunit == "khz":
478
+ new_header["waveunit"] = "kHz"
479
+ elif waveunit == "hz":
480
+ new_header["waveunit"] = "Hz"
481
+
482
+ map = sunpy.map.Map(data, new_header)
483
+ try:
484
+ map_rotated = map.rotate(P_angle)
485
+ except Exception as e:
486
+ print(f"[WARNING] Error rotating map: {e}. Trying another method.")
487
+ try:
488
+ P_angle_deg = P_angle.to(u.deg).value
489
+
490
+ def deg_to_dms_str(deg):
491
+ # Determine the sign and work with the absolute value
492
+ sign = "-" if deg < 0 else ""
493
+ abs_deg = abs(deg)
494
+
495
+ # Extract degrees, minutes, and seconds
496
+ degrees = int(abs_deg)
497
+ minutes_full = (abs_deg - degrees) * 60
498
+ minutes = int(minutes_full)
499
+ seconds = (minutes_full - minutes) * 60
500
+
501
+ # Format string: you can adjust the precision of seconds as needed
502
+ #print(f"[DEBUG] Rotating by {sign}{degrees}d{minutes}m{seconds:.1f}s")
503
+ return f"{sign}{degrees}d{minutes}m{seconds:.1f}s"
504
+
505
+ map_rotated = map.rotate(deg_to_dms_str(P_angle_deg))
506
+ except Exception as e:
507
+ print(f"[ERROR] Error rotating map: {e}")
508
+ map_rotated = None
509
+
510
+ return map_rotated, csys, psf
511
+ elif ndim == 2:
512
+ data = hdu[0].data
513
+ try:
514
+ # Try CRVAL3 first, then fallback to RESTFRQ or FREQ
515
+ if "CRVAL3" in header:
516
+ freq = header["CRVAL3"] * u.Hz
517
+ elif "RESTFRQ" in header:
518
+ freq = header["RESTFRQ"] * u.Hz
519
+ elif "FREQ" in header:
520
+ freq = header["FREQ"] * u.Hz
521
+ else:
522
+ print(f"[WARNING] No frequency keyword found in header")
523
+ freq = None
524
+ except Exception as e:
525
+ print(f"[ERROR] Error getting frequency: {e}")
526
+ freq = None
527
+ try:
528
+ obstime = Time(hdu[0].header["DATE-OBS"])
529
+ except Exception as e:
530
+ print(f"[ERROR] Error getting observation time: {e}")
531
+ obstime = None
532
+ ia_tool = IA()
533
+ ia_tool.open(fits_file)
534
+ metadata_dict = ia_tool.summary(list=False, verbose=True)
535
+ lat, long, height, observatory = extract_telescope_position(metadata_dict)
536
+ ia_tool.close()
537
+ if (
538
+ observatory.upper() == "LOFAR"
539
+ or observatory.upper() == "MWA"
540
+ or observatory.upper() == "MEERKAT"
541
+ or observatory.upper() == "GMRT"
542
+ or observatory.upper() == "UGMRT"
543
+ ):
544
+ #print(f"Observatory: {observatory}")
545
+ pass
546
+ else:
547
+ observatory = None
548
+ POS = get_Earthlocation(fits_file=fits_file, observatory=observatory)
549
+ gcrs = SkyCoord(POS.get_gcrs(obstime))
550
+ reference_coord = SkyCoord(
551
+ header["CRVAL1"] * u.Unit(header["CUNIT1"]),
552
+ header["CRVAL2"] * u.Unit(header["CUNIT2"]),
553
+ frame="gcrs",
554
+ obstime=obstime,
555
+ obsgeoloc=gcrs.cartesian,
556
+ obsgeovel=gcrs.velocity.to_cartesian(),
557
+ distance=gcrs.hcrs.distance,
558
+ )
559
+ # Transform to Helioprojective frame (same as 4D branch)
560
+ reference_coord_arcsec = reference_coord.transform_to(
561
+ Helioprojective(observer=gcrs)
562
+ )
563
+ cdelta_1 = (np.abs(header["CDELT1"]) * u.deg).to(u.arcsec)
564
+ cdelta_2 = (np.abs(header["CDELT2"]) * u.deg).to(u.arcsec)
565
+ P_angle = P(obstime) * -1
566
+ #print(f"Rotating by {P_angle}")
567
+ new_header = sunpy.map.make_fitswcs_header(
568
+ data,
569
+ reference_coord_arcsec,
570
+ reference_pixel=u.Quantity(
571
+ [header["CRPIX1"] - 1, header["CRPIX2"] - 1] * u.pix
572
+ ),
573
+ scale=u.Quantity([cdelta_1, cdelta_2] * u.arcsec / u.pix),
574
+ rotation_angle=P_angle,
575
+ wavelength=freq.to(u.MHz).round(2),
576
+ observatory=observatory,
577
+ )
578
+ new_header["DATE-OBS"] = header.get("DATE-OBS", obstime.isot)
579
+ new_header["TELESCOP"] = observatory
580
+ new_header["INSTRUME"] = header.get("INSTRUME", "Unknown")
581
+ new_header["OBJECT"] = "Sun"
582
+ new_header["ORIGIN"] = "Solar Radio Image Viewer"
583
+ try:
584
+ new_header["FREQ"] = freq.to(u.Hz).value
585
+ new_header["FREQUNIT"] = "Hz"
586
+ except Exception as e:
587
+ print(f"[ERROR] Error adding frequency information: {e}")
588
+ new_header["WCSNAME"] = "Helioprojective"
589
+ new_header["CTYPE1"] = "HPLN-TAN"
590
+ new_header["CTYPE2"] = "HPLT-TAN"
591
+ new_header["CUNIT1"] = "arcsec"
592
+ new_header["CUNIT2"] = "arcsec"
593
+ new_header["STOKES"] = Stokes
594
+ if psf:
595
+ new_header["BMAJ"] = header["BMAJ"]
596
+ new_header["BMIN"] = header["BMIN"]
597
+ # Adjust beam position angle by the P-angle rotation
598
+ original_bpa = header.get("BPA", 0)
599
+ rotated_bpa = original_bpa + P_angle.to(u.deg).value
600
+ new_header["BPA"] = rotated_bpa
601
+ map = sunpy.map.Map(data, new_header)
602
+ try:
603
+ map_rotated = map.rotate(P_angle)
604
+ except Exception as e:
605
+ print(f"[ERROR] Error rotating map: {e}. Trying another method.")
606
+ try:
607
+ P_angle_deg = P_angle.to(u.deg).value
608
+
609
+ def deg_to_dms_str(deg):
610
+ # Determine the sign and work with the absolute value
611
+ sign = "-" if deg < 0 else ""
612
+ abs_deg = abs(deg)
613
+
614
+ map_rotated = map.rotate(deg_to_dms_str(P_angle_deg))
615
+ except Exception as e:
616
+ print(f"[ERROR] Error rotating map: {e}")
617
+ map_rotated = None
618
+
619
+ return map_rotated, csys, psf
620
+ else:
621
+ return None, None, None
622
+
623
+
624
+ def convert_casaimage_to_fits(
625
+ imagename=None, fitsname="temp.fits", dropdeg=False, overwrite=True
626
+ ):
627
+ """
628
+ Convert a CASA image to a FITS file.
629
+ """
630
+ if imagename is None:
631
+ raise ValueError("imagename is required")
632
+ from casatasks import exportfits
633
+
634
+ exportfits(imagename=imagename, fitsimage=fitsname, overwrite=True, dropdeg=dropdeg)
635
+ return fitsname
636
+
637
+
638
+ def plot_helioprojective_map(
639
+ fits_file,
640
+ output_file="helioprojective_map.png",
641
+ cmap="viridis",
642
+ figsize=(14, 12),
643
+ dpi=300,
644
+ show_limb=True,
645
+ show_grid=True,
646
+ **kwargs,
647
+ ):
648
+ """
649
+ Plot a FITS file in helioprojective coordinates and save it to a file.
650
+
651
+ Parameters
652
+ ----------
653
+ fits_file : str
654
+ Path to the FITS file.
655
+ output_file : str, optional
656
+ Path to save the output image. Default is "helioprojective_map.png".
657
+ cmap : str, optional
658
+ Colormap to use for the plot. Default is "viridis".
659
+ figsize : tuple, optional
660
+ Figure size in inches. Default is (14, 12).
661
+ dpi : int, optional
662
+ DPI for the saved image. Default is 300.
663
+ show_limb : bool, optional
664
+ Whether to show the solar limb. Default is True.
665
+ show_grid : bool, optional
666
+ Whether to show coordinate grid lines. Default is True.
667
+ **kwargs : dict
668
+ Additional keyword arguments to pass to convert_to_hpc.
669
+
670
+ Returns
671
+ -------
672
+ map : sunpy.map.Map or None
673
+ The helioprojective map if successful, None otherwise.
674
+ """
675
+ map, cdelta_1, cdelta_2 = convert_to_hpc(fits_file, **kwargs)
676
+ if map is not None:
677
+ # Create a figure with a specific size
678
+ fig = plt.figure(figsize=figsize)
679
+
680
+ # Create a SunPy plot with proper helioprojective coordinates
681
+ ax = fig.add_subplot(111, projection=map)
682
+
683
+ # Plot the map with proper coordinate axes and customized appearance
684
+ im = map.plot(axes=ax, cmap=cmap, title=False)
685
+
686
+ # Add a colorbar with a label
687
+ cbar = plt.colorbar(im, ax=ax, label="Intensity")
688
+ cbar.ax.tick_params(labelsize=12)
689
+
690
+ # Add grid lines for helioprojective coordinates if requested
691
+ if show_grid:
692
+ ax.grid(color="white", linestyle="--", alpha=0.7)
693
+
694
+ # Add a limb (solar edge) overlay if requested
695
+ if show_limb:
696
+ map.draw_limb(axes=ax, color="white", alpha=0.5, linewidth=1.5)
697
+
698
+ # Set title with observation information
699
+ wavelength_str = f"{map.wavelength.value:.2f} {map.wavelength.unit}"
700
+ title = f"Helioprojective Coordinate Map\n{wavelength_str} - {map.date.strftime('%Y-%m-%d %H:%M:%S')}"
701
+ ax.set_title(title, fontsize=16)
702
+
703
+ # Add coordinate labels with larger font
704
+ ax.set_xlabel("Helioprojective Longitude (arcsec)", fontsize=14)
705
+ ax.set_ylabel("Helioprojective Latitude (arcsec)", fontsize=14)
706
+ ax.tick_params(labelsize=12)
707
+
708
+ # Adjust layout to make room for labels
709
+ plt.tight_layout()
710
+
711
+ # Save the figure with high resolution
712
+ plt.savefig(output_file, dpi=dpi, bbox_inches="tight")
713
+ print(f"[INFO] Map saved to {output_file} with proper helioprojective coordinates")
714
+
715
+ return map
716
+ else:
717
+ print("[ERROR] Failed to convert to helioprojective coordinates")
718
+ return None
719
+
720
+
721
+ def save_helioprojective_map(map_obj, output_file):
722
+ """
723
+ Save a helioprojective map as a FITS file.
724
+
725
+ Parameters
726
+ ----------
727
+ map_obj : sunpy.map.Map
728
+ The helioprojective map to save.
729
+ output_file : str
730
+ The path to save the FITS file.
731
+
732
+ Returns
733
+ -------
734
+ bool
735
+ True if the file was saved successfully, False otherwise.
736
+ """
737
+ if not sunpy_imported:
738
+ print("[ERROR] Cannot save helioprojective map without sunpy")
739
+ return False
740
+
741
+ if map_obj is None:
742
+ print("[ERROR] No map to save")
743
+ return False
744
+
745
+ try:
746
+ # Save the map as a FITS file
747
+ map_obj.save(output_file, overwrite=True)
748
+ print(f"[INFO] Helioprojective map saved to {output_file}")
749
+ return True
750
+ except Exception as e:
751
+ print(f"[ERROR] Error saving helioprojective map: {str(e)}")
752
+ return False
753
+
754
+
755
+ def convert_and_save_hpc(
756
+ input_fits_file,
757
+ output_fits_file,
758
+ Stokes="I",
759
+ thres=10,
760
+ lat=None,
761
+ long=None,
762
+ height=None,
763
+ observatory=None,
764
+ overwrite=True,
765
+ temp_suffix="",
766
+ ):
767
+ """
768
+ Convert a FITS file to helioprojective coordinates and save it as a new FITS file.
769
+
770
+ Parameters
771
+ ----------
772
+ input_fits_file : str
773
+ Path to the input FITS file/Casa Image.
774
+ output_fits_file : str
775
+ Path to save the output FITS file.
776
+ Stokes : str, optional
777
+ Stokes parameter to use. Default is "I".
778
+ thres : float, optional
779
+ Threshold value. Default is 10.
780
+ lat : str, optional
781
+ Observer latitude. Default is "-26:42:11.95" (MWA).
782
+ long : str, optional
783
+ Observer longitude. Default is "116:40:14.93" (MWA).
784
+ height : float, optional
785
+ Observer height in meters. Default is 377.8 (MWA).
786
+ observatory : str, optional
787
+ Observatory name. Default is "MWA".
788
+ overwrite : bool, optional
789
+ Whether to overwrite the output file if it exists. Default is True.
790
+ temp_suffix : str, optional
791
+ Suffix to add to temporary files to avoid conflicts. Default is "".
792
+
793
+ Returns
794
+ -------
795
+ bool
796
+ True if the file was saved successfully, False otherwise.
797
+ """
798
+ if not sunpy_imported:
799
+ print("[ERROR] Cannot convert to helioprojective coordinates without sunpy")
800
+ return False
801
+
802
+ try:
803
+ not_fits_flag = False
804
+ original_file = input_fits_file # Keep reference to original
805
+
806
+ # Fix: use AND instead of OR to check if NOT a FITS file
807
+ if not (input_fits_file.endswith(".fits") or input_fits_file.endswith(".fts")):
808
+ # Create unique temp filename with provided suffix
809
+ temp_filename = f"temp{temp_suffix}.fits"
810
+ input_fits_file = convert_casaimage_to_fits(
811
+ imagename=input_fits_file, fitsname=temp_filename
812
+ )
813
+ #print(f"Converted casaimage {original_file} to a temporary fits file ...")
814
+ not_fits_flag = True
815
+
816
+ # Get BUNIT from original file BEFORE any processing
817
+ original_bunit = None
818
+ try:
819
+ from astropy.io import fits as afits
820
+ with afits.open(input_fits_file) as orig_hdu:
821
+ original_bunit = orig_hdu[0].header.get('BUNIT', None)
822
+ #print(f"[HPC] Found BUNIT in original: {original_bunit}")
823
+ except Exception as e:
824
+ print(f"[ERROR] Could not read BUNIT: {e}")
825
+
826
+ # Convert to helioprojective coordinates
827
+ map_obj, cdelta_1, cdelta_2 = convert_to_hpc(
828
+ input_fits_file,
829
+ Stokes=Stokes,
830
+ thres=thres,
831
+ lat=lat,
832
+ long=long,
833
+ height=height,
834
+ observatory=observatory,
835
+ )
836
+ if not_fits_flag:
837
+ # Remove the unique temp file
838
+ temp_filename = f"temp{temp_suffix}.fits"
839
+ os.system(f"rm {temp_filename}")
840
+
841
+ if map_obj is None:
842
+ print("[ERROR] Failed to convert to helioprojective coordinates")
843
+ return False
844
+
845
+ # Save the map as a FITS file
846
+ map_obj.save(output_fits_file, overwrite=overwrite)
847
+
848
+ # Add BUNIT directly to the saved file
849
+ if original_bunit:
850
+ from astropy.io import fits as afits
851
+ with afits.open(output_fits_file, mode='update') as hdul:
852
+ hdul[0].header['BUNIT'] = original_bunit
853
+ hdul.flush()
854
+ #print(f"[DEBUG] Added BUNIT={original_bunit} to HPC file")
855
+
856
+ print(f"[INFO] Helioprojective map saved to {output_fits_file}")
857
+
858
+ # Return the map object for further use if needed
859
+ return True
860
+ except Exception as e:
861
+ print(f"[ERROR] Error converting and saving to helioprojective coordinates: {str(e)}")
862
+ import traceback
863
+
864
+ traceback.print_exc()
865
+ return False
866
+
867
+
868
+ def convert_and_save_hpc_all_stokes(
869
+ input_fits_file,
870
+ output_fits_file,
871
+ thres=10,
872
+ lat=None,
873
+ long=None,
874
+ height=None,
875
+ observatory=None,
876
+ overwrite=True,
877
+ temp_suffix="",
878
+ ):
879
+ """
880
+ Convert a FITS file to helioprojective coordinates for all Stokes parameters,
881
+ and save it as a new FITS file with Stokes axis preserved.
882
+
883
+ Parameters
884
+ ----------
885
+ input_fits_file : str
886
+ Path to the input FITS file/Casa Image.
887
+ output_fits_file : str
888
+ Path to save the output FITS file.
889
+ thres : float, optional
890
+ Threshold value. Default is 10.
891
+ lat : str, optional
892
+ Observer latitude.
893
+ long : str, optional
894
+ Observer longitude.
895
+ height : float, optional
896
+ Observer height in meters.
897
+ observatory : str, optional
898
+ Observatory name.
899
+ overwrite : bool, optional
900
+ Whether to overwrite the output file if it exists. Default is True.
901
+ temp_suffix : str, optional
902
+ Suffix to add to temporary files to avoid conflicts. Default is "".
903
+
904
+ Returns
905
+ -------
906
+ bool
907
+ True if the file was saved successfully, False otherwise.
908
+ """
909
+ if not sunpy_imported:
910
+ print("[ERROR] Cannot convert to helioprojective coordinates without sunpy")
911
+ return False
912
+
913
+ try:
914
+ not_fits_flag = False
915
+ original_input = input_fits_file
916
+
917
+ if not input_fits_file.endswith(".fits") and not input_fits_file.endswith(".fts"):
918
+ # Create unique temp filename with provided suffix
919
+ temp_filename = f"temp{temp_suffix}.fits"
920
+ input_fits_file = convert_casaimage_to_fits(
921
+ imagename=input_fits_file, fitsname=temp_filename
922
+ )
923
+ #print(f"Converted casaimage {original_input} to a temporary fits file ...")
924
+ not_fits_flag = True
925
+
926
+ # Check how many Stokes parameters are available
927
+ hdu = fits.open(input_fits_file)
928
+ header = hdu[0].header
929
+ ndim = header["NAXIS"]
930
+
931
+ # Get BUNIT early before temp file is deleted
932
+ original_bunit = header.get('BUNIT', None)
933
+ if original_bunit:
934
+ #print(f"[DEBUG] Found BUNIT in original: {original_bunit}")
935
+ pass
936
+
937
+ # Find Stokes axis
938
+ stokes_axis_fits = None
939
+ num_stokes = 1
940
+ for i in range(1, ndim + 1):
941
+ if header.get(f"CTYPE{i}", "").upper() == "STOKES":
942
+ stokes_axis_fits = i
943
+ num_stokes = header[f"NAXIS{i}"]
944
+ break
945
+ hdu.close()
946
+
947
+ if stokes_axis_fits is None or num_stokes == 1:
948
+ # Single Stokes - just use regular conversion
949
+ #print("Single Stokes image, using standard conversion")
950
+ return convert_and_save_hpc(
951
+ input_fits_file=original_input,
952
+ output_fits_file=output_fits_file,
953
+ Stokes="I",
954
+ thres=thres,
955
+ lat=lat,
956
+ long=long,
957
+ height=height,
958
+ observatory=observatory,
959
+ overwrite=overwrite,
960
+ temp_suffix=temp_suffix,
961
+ )
962
+
963
+ # Convert each Stokes parameter
964
+ stokes_list = ["I", "Q", "U", "V"][:num_stokes]
965
+ stokes_maps = []
966
+ reference_header = None
967
+
968
+ for stokes in stokes_list:
969
+ #print(f"Converting Stokes {stokes} to helioprojective...")
970
+ try:
971
+ map_obj, csys, psf = convert_to_hpc(
972
+ input_fits_file,
973
+ Stokes=stokes,
974
+ thres=thres,
975
+ lat=lat,
976
+ long=long,
977
+ height=height,
978
+ observatory=observatory,
979
+ )
980
+ if map_obj is not None:
981
+ stokes_maps.append(map_obj.data)
982
+ if reference_header is None:
983
+ reference_header = dict(map_obj.meta)
984
+ else:
985
+ print(f"[ERROR] Failed to convert Stokes {stokes}, using zeros")
986
+ if stokes_maps:
987
+ stokes_maps.append(np.zeros_like(stokes_maps[0]))
988
+ else:
989
+ raise RuntimeError(f"Failed to convert first Stokes {stokes}")
990
+ except Exception as e:
991
+ print(f"[ERROR] Error converting Stokes {stokes}: {e}")
992
+ if stokes_maps:
993
+ stokes_maps.append(np.zeros_like(stokes_maps[0]))
994
+ else:
995
+ raise
996
+
997
+ if not_fits_flag:
998
+ # Remove the temp file
999
+ temp_filename = f"temp{temp_suffix}.fits"
1000
+ os.system(f"rm {temp_filename}")
1001
+
1002
+ if not stokes_maps:
1003
+ print("[ERROR] No Stokes maps were successfully converted")
1004
+ return False
1005
+
1006
+ # Ensure all Stokes maps have the same shape (reference is first map - Stokes I)
1007
+ reference_shape = stokes_maps[0].shape
1008
+ aligned_maps = [stokes_maps[0]]
1009
+
1010
+ for i, smap in enumerate(stokes_maps[1:], 1):
1011
+ if smap.shape != reference_shape:
1012
+ # print(f"[DEBUG] Stokes {stokes_list[i]} shape {smap.shape} differs from reference {reference_shape}, resampling...")
1013
+ # Resize to match reference shape
1014
+ from scipy.ndimage import zoom
1015
+ zoom_factors = (reference_shape[0] / smap.shape[0], reference_shape[1] / smap.shape[1])
1016
+ smap = zoom(smap, zoom_factors, order=1)
1017
+ # Ensure exact shape match after zoom
1018
+ smap = smap[:reference_shape[0], :reference_shape[1]]
1019
+ aligned_maps.append(smap)
1020
+
1021
+ # Stack all Stokes into a single 3D array
1022
+ stokes_data = np.stack(aligned_maps, axis=0)
1023
+ #print(f"Combined Stokes data shape: {stokes_data.shape}")
1024
+
1025
+ # Create new header with Stokes axis
1026
+ from astropy.io import fits as afits
1027
+
1028
+ # BUNIT was already read earlier (before temp file deletion)
1029
+
1030
+ new_header = afits.Header()
1031
+ for key, value in reference_header.items():
1032
+ if key not in ['COMMENT', 'HISTORY', '']:
1033
+ try:
1034
+ new_header[key] = value
1035
+ except Exception:
1036
+ pass
1037
+
1038
+ # Update for 3D with Stokes
1039
+ new_header['NAXIS'] = 3
1040
+ new_header['NAXIS3'] = len(stokes_maps)
1041
+ new_header['CTYPE3'] = 'STOKES'
1042
+ new_header['CRVAL3'] = 1.0
1043
+ new_header['CDELT3'] = 1.0
1044
+ new_header['CRPIX3'] = 1.0
1045
+ new_header['CUNIT3'] = ''
1046
+
1047
+ # Preserve BUNIT from original file
1048
+ if original_bunit:
1049
+ new_header['BUNIT'] = original_bunit
1050
+ #print(f"[HPC] Added BUNIT={original_bunit} to multi-Stokes HPC file")
1051
+
1052
+ # Remove single-Stokes marker if present
1053
+ if 'STOKES' in new_header:
1054
+ del new_header['STOKES']
1055
+
1056
+ # Write the combined FITS file
1057
+ hdu_out = afits.PrimaryHDU(stokes_data, header=new_header)
1058
+ hdu_out.writeto(output_fits_file, overwrite=overwrite)
1059
+ #print(f"Helioprojective map with {len(stokes_maps)} Stokes saved to {output_fits_file}")
1060
+
1061
+ return True
1062
+
1063
+ except Exception as e:
1064
+ print(f"[ERROR] Error converting and saving all Stokes to helioprojective: {str(e)}")
1065
+ import traceback
1066
+ traceback.print_exc()
1067
+ return False
1068
+
1069
+
1070
+ def convert_hpc_to_radec(
1071
+ input_fits_file,
1072
+ output_fits_file,
1073
+ overwrite=True,
1074
+ ):
1075
+ """
1076
+ Convert a helioprojective (Solar-X/Y) FITS file back to RA/Dec coordinates.
1077
+
1078
+ This actually rotates the data by the P-angle to undo the solar rotation.
1079
+
1080
+ Parameters
1081
+ ----------
1082
+ input_fits_file : str
1083
+ Path to the input helioprojective FITS file.
1084
+ output_fits_file : str
1085
+ Path to save the output RA/Dec FITS file.
1086
+ overwrite : bool, optional
1087
+ Whether to overwrite the output file if it exists. Default is True.
1088
+
1089
+ Returns
1090
+ -------
1091
+ bool
1092
+ True if the file was saved successfully, False otherwise.
1093
+ """
1094
+ if not sunpy_imported:
1095
+ print("[ERROR] Cannot convert from helioprojective coordinates without sunpy")
1096
+ return False
1097
+
1098
+ try:
1099
+ from astropy.io import fits as afits
1100
+ from astropy.wcs import WCS
1101
+ from scipy.ndimage import rotate
1102
+
1103
+ #print(f"[HPC->RA/Dec] Converting {input_fits_file}")
1104
+
1105
+ # Load the HPC FITS file
1106
+ with afits.open(input_fits_file) as hdul:
1107
+ hpc_data = hdul[0].data.copy()
1108
+ hpc_header = hdul[0].header.copy()
1109
+
1110
+ # Check if it's actually HPC
1111
+ ctype1 = hpc_header.get('CTYPE1', '').upper()
1112
+ ctype2 = hpc_header.get('CTYPE2', '').upper()
1113
+ if 'HPLN' not in ctype1 and 'HPLT' not in ctype2 and 'SOLAR' not in ctype1:
1114
+ print(f"[ERROR] File does not appear to be in HPC coordinates (CTYPE1={ctype1}, CTYPE2={ctype2})")
1115
+ return False
1116
+
1117
+ # Get observation time
1118
+ obstime_str = hpc_header.get('DATE-OBS', None)
1119
+ if obstime_str is None:
1120
+ print("[ERROR] DATE-OBS not found in header")
1121
+ return False
1122
+ obstime = Time(obstime_str)
1123
+
1124
+ # Get P-angle - this is the rotation we need to undo
1125
+ p_angle = P(obstime).to(u.deg).value
1126
+ #print(f"[HPC->RA/Dec] P-angle: {p_angle:.2f} deg - will rotate by {-p_angle:.2f} deg")
1127
+
1128
+ # Get Sun center in RA/Dec at observation time
1129
+ sun_coord = SkyCoord(0*u.arcsec, 0*u.arcsec,
1130
+ frame=Helioprojective(obstime=obstime, observer='earth'))
1131
+ sun_radec = sun_coord.transform_to('gcrs')
1132
+ sun_ra = sun_radec.ra.deg
1133
+ sun_dec = sun_radec.dec.deg
1134
+ #print(f"[HPC->RA/Dec] Sun center at {obstime.iso}: RA={sun_ra:.4f}, Dec={sun_dec:.4f}")
1135
+
1136
+ # Get HPC coordinate parameters
1137
+ crpix1 = hpc_header.get('CRPIX1', hpc_data.shape[-1]/2)
1138
+ crpix2 = hpc_header.get('CRPIX2', hpc_data.shape[-2]/2)
1139
+ cdelt1 = hpc_header.get('CDELT1', 1.0)
1140
+ cdelt2 = hpc_header.get('CDELT2', 1.0)
1141
+
1142
+ # Rotate the data by -P_angle to undo the solar rotation
1143
+ # scipy.ndimage.rotate uses counter-clockwise positive angles
1144
+ # so we negate to rotate clockwise (undoing the original rotation)
1145
+ # Using reshape=False to maintain original image dimensions
1146
+ if hpc_data.ndim == 2:
1147
+ rotated_data = rotate(hpc_data, -p_angle, reshape=False, order=1, mode='constant', cval=np.nan)
1148
+ elif hpc_data.ndim == 3:
1149
+ # Rotate each Stokes plane separately
1150
+ rotated_data = np.zeros_like(hpc_data)
1151
+ for i in range(hpc_data.shape[0]):
1152
+ rotated_data[i] = rotate(hpc_data[i], -p_angle, reshape=False, order=1, mode='constant', cval=np.nan)
1153
+ else:
1154
+ print(f"[ERROR] Unexpected data dimensions: {hpc_data.ndim}")
1155
+ return False
1156
+
1157
+ # Crop to non-NaN bounding box to remove NaN borders
1158
+ if rotated_data.ndim == 2:
1159
+ valid_mask = ~np.isnan(rotated_data)
1160
+ else: # 3D
1161
+ # Use union of all planes for the mask
1162
+ valid_mask = ~np.isnan(rotated_data[0])
1163
+ for i in range(1, rotated_data.shape[0]):
1164
+ valid_mask |= ~np.isnan(rotated_data[i])
1165
+
1166
+ # Find bounding box of valid data
1167
+ rows = np.any(valid_mask, axis=1)
1168
+ cols = np.any(valid_mask, axis=0)
1169
+ if not np.any(rows) or not np.any(cols):
1170
+ print("[ERROR] No valid data after rotation")
1171
+ return False
1172
+
1173
+ rmin, rmax = np.where(rows)[0][[0, -1]]
1174
+ cmin, cmax = np.where(cols)[0][[0, -1]]
1175
+
1176
+ # Add small padding to avoid over-cropping due to interpolation edge effects
1177
+ padding = 0 # pixels
1178
+ if rotated_data.ndim == 2:
1179
+ rmin = max(0, rmin - padding)
1180
+ rmax = min(rotated_data.shape[0] - 1, rmax + padding)
1181
+ cmin = max(0, cmin - padding)
1182
+ cmax = min(rotated_data.shape[1] - 1, cmax + padding)
1183
+ else: # 3D
1184
+ rmin = max(0, rmin - padding)
1185
+ rmax = min(rotated_data.shape[1] - 1, rmax + padding)
1186
+ cmin = max(0, cmin - padding)
1187
+ cmax = min(rotated_data.shape[2] - 1, cmax + padding)
1188
+
1189
+ # Crop the data
1190
+ if rotated_data.ndim == 2:
1191
+ rotated_data = rotated_data[rmin:rmax+1, cmin:cmax+1]
1192
+ else:
1193
+ rotated_data = rotated_data[:, rmin:rmax+1, cmin:cmax+1]
1194
+
1195
+ # Adjust CRPIX for the crop
1196
+ crpix1 = crpix1 - cmin
1197
+ crpix2 = crpix2 - rmin
1198
+
1199
+ #print(f"[HPC->RA/Dec] Cropped to {rotated_data.shape[-1]}x{rotated_data.shape[-2]} (removed NaN borders)")
1200
+
1201
+ # Convert cdelt from arcsec to degrees if needed
1202
+ cunit1 = hpc_header.get('CUNIT1', 'arcsec').lower()
1203
+ cunit2 = hpc_header.get('CUNIT2', 'arcsec').lower()
1204
+ if 'arcsec' in cunit1:
1205
+ cdelt1_deg = cdelt1 / 3600.0
1206
+ elif 'deg' in cunit1:
1207
+ cdelt1_deg = cdelt1
1208
+ else:
1209
+ cdelt1_deg = cdelt1 / 3600.0
1210
+
1211
+ if 'arcsec' in cunit2:
1212
+ cdelt2_deg = cdelt2 / 3600.0
1213
+ elif 'deg' in cunit2:
1214
+ cdelt2_deg = cdelt2
1215
+ else:
1216
+ cdelt2_deg = cdelt2 / 3600.0
1217
+
1218
+ # Create new RA/Dec header
1219
+ new_header = afits.Header()
1220
+
1221
+ # Copy essential keywords
1222
+ for key in ['SIMPLE', 'BITPIX', 'NAXIS', 'NAXIS1', 'NAXIS2',
1223
+ 'BMAJ', 'BMIN', 'BPA', 'BUNIT', 'TELESCOP', 'INSTRUME',
1224
+ 'DATE-OBS', 'FREQ', 'FREQUNIT', 'OBJECT', 'ORIGIN',
1225
+ 'CRVAL3', 'CRVAL4', 'RESTFRQ']:
1226
+ if key in hpc_header:
1227
+ new_header[key] = hpc_header[key]
1228
+
1229
+ # Handle Stokes axis
1230
+ if hpc_data.ndim == 3:
1231
+ # Save frequency before CRVAL3 is overwritten for Stokes
1232
+ orig_freq = hpc_header.get('CRVAL3') or hpc_header.get('RESTFRQ') or hpc_header.get('FREQ')
1233
+
1234
+ new_header['NAXIS'] = 3
1235
+ new_header['NAXIS3'] = hpc_data.shape[0]
1236
+ new_header['CTYPE3'] = 'STOKES'
1237
+ new_header['CRVAL3'] = 1.0
1238
+ new_header['CDELT3'] = 1.0
1239
+ new_header['CRPIX3'] = 1.0
1240
+
1241
+ # Preserve frequency in RESTFRQ if it was in CRVAL3
1242
+ if orig_freq and 'RESTFRQ' not in new_header:
1243
+ new_header['RESTFRQ'] = float(orig_freq)
1244
+
1245
+ # Set RA/Dec coordinate system (SIN projection for radio)
1246
+ new_header['CTYPE1'] = 'RA---SIN'
1247
+ new_header['CTYPE2'] = 'DEC--SIN'
1248
+ new_header['CUNIT1'] = 'deg'
1249
+ new_header['CUNIT2'] = 'deg'
1250
+ new_header['CRPIX1'] = crpix1
1251
+ new_header['CRPIX2'] = crpix2
1252
+ new_header['CDELT1'] = -abs(cdelt1_deg) # RA increases to the left
1253
+ new_header['CDELT2'] = abs(cdelt2_deg)
1254
+ new_header['CRVAL1'] = sun_ra
1255
+ new_header['CRVAL2'] = sun_dec
1256
+
1257
+ # No PC matrix needed - rotation already applied to data
1258
+ new_header['PC1_1'] = 1.0
1259
+ new_header['PC1_2'] = 0.0
1260
+ new_header['PC2_1'] = 0.0
1261
+ new_header['PC2_2'] = 1.0
1262
+
1263
+ new_header['RADESYS'] = 'ICRS'
1264
+ new_header['EQUINOX'] = 2000.0
1265
+
1266
+ new_header['HISTORY'] = f'Converted from Helioprojective to RA/Dec by SolarViewer (rotated {-p_angle:.2f} deg)'
1267
+
1268
+ # Write output file
1269
+ hdu = afits.PrimaryHDU(data=rotated_data, header=new_header)
1270
+ hdu.writeto(output_fits_file, overwrite=overwrite)
1271
+
1272
+ #print(f"[HPC->RA/Dec] Saved rotated RA/Dec file to: {output_fits_file}")
1273
+ return True
1274
+
1275
+ except Exception as e:
1276
+ print(f"[ERROR] Error converting HPC to RA/Dec: {str(e)}")
1277
+ import traceback
1278
+ traceback.print_exc()
1279
+ return False
1280
+
1281
+
1282
+ def itrf_to_geodetic(x, y, z, scale_factor=1.0):
1283
+ """
1284
+ Convert ITRF (International Terrestrial Reference Frame) coordinates to geodetic coordinates.
1285
+
1286
+ ITRF coordinates are Earth-centered, Earth-fixed Cartesian coordinates.
1287
+ Typical ITRF coordinates are in the range of millions of meters from the Earth's center.
1288
+ For example, a point on the Earth's surface might have ITRF coordinates like:
1289
+ X = 4075539.5, Y = 931735.8, Z = 4801629.6 (in meters)
1290
+
1291
+ Parameters
1292
+ ----------
1293
+ x : float
1294
+ X coordinate in meters.
1295
+ y : float
1296
+ Y coordinate in meters.
1297
+ z : float
1298
+ Z coordinate in meters.
1299
+ scale_factor : float, optional
1300
+ Scale factor to apply to the coordinates. Default is 1.0.
1301
+ This is useful if the coordinates are provided in different units.
1302
+
1303
+ Returns
1304
+ -------
1305
+ lat : str
1306
+ Latitude in the format "DD:MM:SS.SS".
1307
+ long : str
1308
+ Longitude in the format "DD:MM:SS.SS".
1309
+ height : float
1310
+ Height in meters above the WGS84 ellipsoid.
1311
+
1312
+ Examples
1313
+ --------
1314
+ >>> lat, long, height = itrf_to_geodetic(4075539.5, 931735.8, 4801629.6)
1315
+ >>> print(lat, long, height)
1316
+ '49:08:42.04' '12:52:38.85' 669.13
1317
+ """
1318
+ try:
1319
+ # Apply scale factor if provided
1320
+ x = x * scale_factor
1321
+ y = y * scale_factor
1322
+ z = z * scale_factor
1323
+
1324
+ # Use astropy's EarthLocation for the conversion if available
1325
+ try:
1326
+ # Create EarthLocation object from ITRF coordinates
1327
+ location = EarthLocation.from_geocentric(x, y, z, unit=u.m)
1328
+
1329
+ # Get latitude and longitude in degrees
1330
+ lat_deg = location.lat.deg
1331
+ lon_deg = location.lon.deg
1332
+ height = location.height.value # in meters
1333
+
1334
+ except Exception as e:
1335
+ print(f"[ERROR] Error {str(e)}. Falling back to manual calculation.")
1336
+ # Fall back to manual calculation if astropy is not available
1337
+ # WGS84 ellipsoid parameters
1338
+ a = 6378137.0 # semi-major axis in meters
1339
+ f = 1 / 298.257223563 # flattening
1340
+ b = a * (1 - f) # semi-minor axis
1341
+ e_sq = 1 - (b / a) ** 2 # eccentricity squared
1342
+
1343
+ # Calculate longitude (easy)
1344
+ longitude = np.arctan2(y, x)
1345
+
1346
+ # Iterative calculation of latitude and height
1347
+ p = np.sqrt(x**2 + y**2)
1348
+
1349
+ # Initial guess
1350
+ latitude = np.arctan2(z, p * (1 - e_sq))
1351
+
1352
+ # Iterative improvement
1353
+ for _ in range(5): # Usually converges in a few iterations
1354
+ N = a / np.sqrt(1 - e_sq * np.sin(latitude) ** 2)
1355
+ height = p / np.cos(latitude) - N
1356
+ latitude = np.arctan2(z, p * (1 - e_sq * N / (N + height)))
1357
+
1358
+ # Convert to degrees
1359
+ lat_deg = np.degrees(latitude)
1360
+ lon_deg = np.degrees(longitude)
1361
+
1362
+ # Convert decimal degrees to DD:MM:SS.SS format
1363
+ def decimal_to_dms(decimal_degrees):
1364
+ is_negative = decimal_degrees < 0
1365
+ decimal_degrees = abs(decimal_degrees)
1366
+
1367
+ degrees = int(decimal_degrees)
1368
+ decimal_minutes = (decimal_degrees - degrees) * 60
1369
+ minutes = int(decimal_minutes)
1370
+ seconds = (decimal_minutes - minutes) * 60
1371
+
1372
+ # Format as DD:MM:SS.SS
1373
+ dms = f"{degrees:02d}:{minutes:02d}:{seconds:05.2f}"
1374
+
1375
+ # Add negative sign if needed
1376
+ if is_negative:
1377
+ dms = f"-{dms}"
1378
+
1379
+ return dms
1380
+
1381
+ lat_str = decimal_to_dms(lat_deg)
1382
+ lon_str = decimal_to_dms(lon_deg)
1383
+
1384
+ return lat_str, lon_str, height
1385
+
1386
+ except Exception as e:
1387
+ print(f"[ERROR] Error converting ITRF to geodetic: {str(e)}")
1388
+ import traceback
1389
+
1390
+ traceback.print_exc()
1391
+ return None, None, None
1392
+
1393
+
1394
+ def geodetic_to_itrf(lat, lon, height):
1395
+ """
1396
+ Convert geodetic coordinates to ITRF (International Terrestrial Reference Frame) coordinates.
1397
+
1398
+ Parameters
1399
+ ----------
1400
+ lat : str or float
1401
+ Latitude in the format "DD:MM:SS.SS" or decimal degrees.
1402
+ lon : str or float
1403
+ Longitude in the format "DD:MM:SS.SS" or decimal degrees.
1404
+ height : float
1405
+ Height in meters above the WGS84 ellipsoid.
1406
+
1407
+ Returns
1408
+ -------
1409
+ x : float
1410
+ X coordinate in meters.
1411
+ y : float
1412
+ Y coordinate in meters.
1413
+ z : float
1414
+ Z coordinate in meters.
1415
+
1416
+ Examples
1417
+ --------
1418
+ >>> x, y, z = geodetic_to_itrf("49:08:42.04", "12:52:38.85", 669.13)
1419
+ >>> print(f"{x:.1f}, {y:.1f}, {z:.1f}")
1420
+ '4075539.5, 931735.8, 4801629.6'
1421
+ """
1422
+ try:
1423
+ # Convert lat/lon from string to decimal degrees if needed
1424
+ if isinstance(lat, str):
1425
+ lat = dms_to_decimal(lat)
1426
+ if isinstance(lon, str):
1427
+ lon = dms_to_decimal(lon)
1428
+
1429
+ # Use astropy's EarthLocation for the conversion if available
1430
+ try:
1431
+ # Create EarthLocation object from geodetic coordinates
1432
+ location = EarthLocation.from_geodetic(lon, lat, height, unit=u.m)
1433
+
1434
+ # Get ITRF coordinates
1435
+ x = location.x.value
1436
+ y = location.y.value
1437
+ z = location.z.value
1438
+
1439
+ except Exception as e:
1440
+ print(f"[ERROR] Error {str(e)}. Falling back to manual calculation.")
1441
+ # Convert to radians
1442
+ lat_rad = np.radians(lat)
1443
+ lon_rad = np.radians(lon)
1444
+
1445
+ # WGS84 ellipsoid parameters
1446
+ a = 6378137.0 # semi-major axis in meters
1447
+ f = 1 / 298.257223563 # flattening
1448
+ e_sq = 2 * f - f**2 # eccentricity squared
1449
+
1450
+ # Calculate N (radius of curvature in the prime vertical)
1451
+ N = a / np.sqrt(1 - e_sq * np.sin(lat_rad) ** 2)
1452
+
1453
+ # Calculate ECEF coordinates
1454
+ x = (N + height) * np.cos(lat_rad) * np.cos(lon_rad)
1455
+ y = (N + height) * np.cos(lat_rad) * np.sin(lon_rad)
1456
+ z = (N * (1 - e_sq) + height) * np.sin(lat_rad)
1457
+
1458
+ return x, y, z
1459
+
1460
+ except Exception as e:
1461
+ print(f"[ERROR] Error converting geodetic to ITRF: {str(e)}")
1462
+ import traceback
1463
+
1464
+ traceback.print_exc()
1465
+ return None, None, None
1466
+
1467
+
1468
+ def dms_to_decimal(dms_str):
1469
+ """
1470
+ Convert a coordinate string in DD:MM:SS.SS format to decimal degrees.
1471
+
1472
+ Parameters
1473
+ ----------
1474
+ dms_str : str
1475
+ Coordinate string in the format "DD:MM:SS.SS" or "-DD:MM:SS.SS".
1476
+
1477
+ Returns
1478
+ -------
1479
+ float
1480
+ Coordinate in decimal degrees.
1481
+ """
1482
+ try:
1483
+ # Check if the string is negative
1484
+ is_negative = dms_str.startswith("-")
1485
+ if is_negative:
1486
+ dms_str = dms_str[1:] # Remove the negative sign
1487
+
1488
+ # Split the string into degrees, minutes, and seconds
1489
+ parts = dms_str.split(":")
1490
+ if len(parts) != 3:
1491
+ raise ValueError(
1492
+ f"Invalid DMS format: {dms_str}. Expected format: DD:MM:SS.SS"
1493
+ )
1494
+
1495
+ degrees = float(parts[0])
1496
+ minutes = float(parts[1])
1497
+ seconds = float(parts[2])
1498
+
1499
+ # Convert to decimal degrees
1500
+ decimal = degrees + minutes / 60 + seconds / 3600
1501
+
1502
+ # Apply negative sign if needed
1503
+ if is_negative:
1504
+ decimal = -decimal
1505
+
1506
+ return decimal
1507
+
1508
+ except Exception as e:
1509
+ print(f"[ERROR] Error converting DMS to decimal: {str(e)}")
1510
+ raise
1511
+
1512
+
1513
+ def extract_telescope_position(metadata_dict):
1514
+ """
1515
+ Extract telescope position from metadata dictionary and convert to latitude, longitude, and height.
1516
+
1517
+ Parameters
1518
+ ----------
1519
+ metadata_dict : dict
1520
+ Metadata dictionary containing telescope position information.
1521
+
1522
+ Returns
1523
+ -------
1524
+ lat : str
1525
+ Latitude in the format "DD:MM:SS.SS".
1526
+ long : str
1527
+ Longitude in the format "DD:MM:SS.SS".
1528
+ height : float
1529
+ Height in meters above the WGS84 ellipsoid.
1530
+ observatory : str
1531
+ Observatory name if available, otherwise None.
1532
+
1533
+ Examples
1534
+ --------
1535
+ >>> metadata = {'messages': [
1536
+ ... 'Telescope position: [-2.55945e+06m, 5.09537e+06m, -2.84906e+06m] (ITRF)',
1537
+ ... 'Telescope: MWA'
1538
+ ... ]}
1539
+ >>> lat, lon, height, observatory = extract_telescope_position(metadata)
1540
+ >>> print(lat, lon, height, observatory)
1541
+ '-26:42:11.95' '116:40:14.93' 377.8 'MWA'
1542
+ """
1543
+ try:
1544
+ # Extract telescope position from messages
1545
+ telescope_position = None
1546
+ observatory = None
1547
+
1548
+ if "messages" in metadata_dict:
1549
+ for message in metadata_dict["messages"]:
1550
+ # Look for telescope position in the message
1551
+ if "Telescope position:" in message:
1552
+ # Extract the position string
1553
+ pos_start = message.find("Telescope position:") + len(
1554
+ "Telescope position:"
1555
+ )
1556
+ pos_end = (
1557
+ message.find("(ITRF)") if "(ITRF)" in message else len(message)
1558
+ )
1559
+ telescope_position = message[pos_start:pos_end].strip()
1560
+
1561
+ # Look for telescope/observatory name
1562
+ for line in message.split("\n"):
1563
+ if "Telescope" in line and ":" in line and "position" not in line:
1564
+ parts = line.split(":")
1565
+ if len(parts) > 1:
1566
+ observatory = parts[1].strip()
1567
+ break
1568
+
1569
+ # If observatory is still None, check if it's directly in the metadata
1570
+ if observatory is None and "observatory" in metadata_dict:
1571
+ observatory = metadata_dict["observatory"]
1572
+
1573
+ if telescope_position is None:
1574
+ print("[ERROR] Telescope position not found in metadata")
1575
+ return None, None, None, observatory
1576
+
1577
+ # Extract the ITRF coordinates
1578
+ # Remove brackets and 'm' units, then split by comma
1579
+ telescope_position = telescope_position.replace("[", "").replace("]", "")
1580
+ coords = telescope_position.split(",")
1581
+
1582
+ if len(coords) != 3:
1583
+ print(f"[ERROR] Invalid telescope position format: {telescope_position}")
1584
+ return None, None, None, observatory
1585
+
1586
+ # Parse the coordinates
1587
+ x = float(coords[0].replace("m", "").strip())
1588
+ y = float(coords[1].replace("m", "").strip())
1589
+ z = float(coords[2].replace("m", "").strip())
1590
+ if abs(x) < 1e6 and abs(y) < 1e6 and abs(z) < 1e6:
1591
+ print(f"[WARNING] Invalid telescope position format: {telescope_position}")
1592
+ print(f"Checking if it is a known telescope ....")
1593
+ if observatory.upper() == "LOFAR":
1594
+ print(f"Observatory matched with LOFAR")
1595
+ lat = "52:55:53.90"
1596
+ lon = "06:51:56.95"
1597
+ height = 50.16
1598
+ elif observatory.upper() == "MWA":
1599
+ print(f"Observatory matched with MWA")
1600
+ lat = "-26:42:12.09"
1601
+ lon = "116:40:14.84"
1602
+ height = 375.75
1603
+ elif observatory.upper() == "MEERKAT":
1604
+ print(f"Observatory matched with MEERKAT")
1605
+ lat = "-30:42:47.36"
1606
+ lon = "21:26:38.09"
1607
+ height = 1050.82
1608
+ elif observatory.upper() == "GMRT" or observatory.upper() == "UGMRT":
1609
+ print(f"Observatory matched with uGMRT")
1610
+ lat = "19:05:26.21"
1611
+ lon = "74:02:59.90"
1612
+ height = 639.68
1613
+ else:
1614
+ print(f"Observatory not matched with database of known telescopes")
1615
+ return None, None, None, observatory
1616
+ else:
1617
+ # Convert ITRF coordinates to latitude, longitude, and height
1618
+ lat, lon, height = itrf_to_geodetic(x, y, z)
1619
+
1620
+ return lat, lon, height, observatory
1621
+
1622
+ except Exception as e:
1623
+ print(f"[ERROR] Error extracting telescope position: {str(e)}")
1624
+ import traceback
1625
+
1626
+ traceback.print_exc()
1627
+ return None, None, None, None
1628
+
1629
+
1630
+ def test_extract_telescope_position():
1631
+ """
1632
+ Test the extract_telescope_position function with a sample metadata dictionary.
1633
+ """
1634
+ # Sample metadata dictionary
1635
+ metadata = {
1636
+ "axisnames": ["Right Ascension", "Declination", "Stokes", "Frequency"],
1637
+ "axisunits": ["rad", "rad", "", "Hz"],
1638
+ "defaultmask": "",
1639
+ "hasmask": False,
1640
+ "imagetype": "Intensity",
1641
+ "incr": [-2.08469883e-04, 2.08469883e-04, 1.00000000e00, 1.60000000e05],
1642
+ "masks": [],
1643
+ "messages": [
1644
+ "\nImage name : solar_2014_11_03_06_12_50.20_125.995MHz.image\nObject name : Sun\nImage type : PagedImage\nImage quantity : Intensity\nPixel mask(s) : None\nRegion(s) : None\nImage units : Jy/beam\nRestoring Beam : 408.885 arcsec, 303.362 arcsec, -67.7405 deg",
1645
+ "\nDirection reference : J2000\nSpectral reference : Undefined\nVelocity type : RADIO\nTelescope : MWA\nObserver : DivyaOberoi\nDate observation : 2014/11/03/06:12:50.200000\nTelescope position: [-2.55945e+06m, 5.09537e+06m, -2.84906e+06m] (ITRF)\n\nAxis Coord Type Name Proj Shape Tile Coord value at pixel Coord incr Units\n------------------------------------------------------------------------------------------------ \n0 0 Direction Right Ascension SIN 1280 160 14:30:29.637 640.00 -4.300000e+01 arcsec\n1 0 Direction Declination SIN 1280 160 -14.58.57.711 640.00 4.300000e+01 arcsec\n2 1 Stokes Stokes 4 1 I Q U V\n3 2 Spectral Frequency 1 1 1.25995e+08 0.00 1.600000e+05 Hz",
1646
+ ],
1647
+ "ndim": 4,
1648
+ "refpix": [640.0, 640.0, 0.0, 0.0],
1649
+ "refval": [-2.48493895e00, -2.61497402e-01, 1.00000000e00, 1.25995000e08],
1650
+ "restoringbeam": {
1651
+ "major": {"unit": "arcsec", "value": 408.88454881068},
1652
+ "minor": {"unit": "arcsec", "value": 303.36225356523596},
1653
+ "positionangle": {"unit": "deg", "value": -67.74046593535},
1654
+ },
1655
+ "shape": [1280, 1280, 4, 1],
1656
+ "tileshape": [160, 160, 1, 1],
1657
+ "unit": "Jy/beam",
1658
+ }
1659
+
1660
+ # Test with the original metadata
1661
+ #print("\nTest 1: Original metadata")
1662
+ lat, lon, height, observatory = extract_telescope_position(metadata)
1663
+
1664
+ # Print the results
1665
+ #print("\nExtracted Telescope Position:")
1666
+ #print(f"Latitude: {lat}")
1667
+ #print(f"Longitude: {lon}")
1668
+ #print(f"Height: {height} meters")
1669
+ #print(f"Observatory: {observatory}")
1670
+
1671
+ # Print the values to use in convert_to_hpc
1672
+ #print("\nUse these values in convert_to_hpc as:")
1673
+ #print(f'lat="{lat}", long="{lon}", height={height}, observatory="{observatory}"')
1674
+
1675
+ # Test with metadata that has observatory directly in the dictionary
1676
+ #print("\nTest 2: Metadata with observatory directly in the dictionary")
1677
+ metadata2 = metadata.copy()
1678
+ metadata2["observatory"] = "MWA"
1679
+
1680
+ lat2, lon2, height2, observatory2 = extract_telescope_position(metadata2)
1681
+
1682
+ # Print the results
1683
+ #print("\nExtracted Telescope Position:")
1684
+ #print(f"Latitude: {lat2}")
1685
+ #print(f"Longitude: {lon2}")
1686
+ #print(f"Height: {height2} meters")
1687
+ #print(f"Observatory: {observatory2}")
1688
+
1689
+ # Print the values to use in convert_to_hpc
1690
+ #print("\nUse these values in convert_to_hpc as:")
1691
+ #print(
1692
+ # f'lat="{lat2}", long="{lon2}", height={height2}, observatory="{observatory2}"'
1693
+ #)
1694
+
1695
+ return lat, lon, height, observatory
1696
+
1697
+
1698
+ def main():
1699
+ """fits_file = "/home/soham/solarviewer/test_data/mwa_solar.fits"
1700
+ hpc_map, c1, c2 = convert_to_hpc(
1701
+ fits_file,
1702
+ lat="52:55:53.90",
1703
+ long="06:51:56.95",
1704
+ height=50.16,
1705
+ observatory="LOFAR",
1706
+ Stokes="Lfrac",
1707
+ )
1708
+ fig = plt.figure()
1709
+ ax = fig.add_subplot(111, projection=hpc_map)
1710
+ im = hpc_map.plot(axes=ax, cmap="gray", title=False)
1711
+ plt.savefig("hpc_map.png")"""
1712
+
1713
+ # Example 1: Basic usage with default parameters
1714
+ """plot_helioprojective_map(fits_file)
1715
+
1716
+ # Example 2: Customized plot
1717
+ plot_helioprojective_map(
1718
+ fits_file,
1719
+ output_file="custom_helioprojective_map.png",
1720
+ cmap="hot",
1721
+ figsize=(10, 10),
1722
+ dpi=150,
1723
+ show_limb=True,
1724
+ show_grid=False,
1725
+ observatory="LOFAR", # This is passed to convert_to_hpc
1726
+ )"""
1727
+
1728
+
1729
+ if __name__ == "__main__":
1730
+ # main()
1731
+ """imagename = "/home/soham/solarviewer/test_data/LOFAR_HBA_noisestorm.fits"
1732
+ from casatools import image as IA
1733
+
1734
+ ia = IA()
1735
+ ia.open(imagename)
1736
+ metadata = ia.summary(list=False)
1737
+ ia.close()
1738
+ print(metadata)
1739
+ lat, lon, height, observatory = extract_telescope_position(metadata)
1740
+ print(f"Latitude: {lat}")
1741
+ print(f"Longitude: {lon}")
1742
+ print(f"Height: {height} meters")
1743
+ print(f"Observatory: {observatory}")"""
1744
+ import argparse
1745
+
1746
+ # Create the argument parser
1747
+ parser = argparse.ArgumentParser(
1748
+ description="Convert a FITS file to helioprojective coordinates and save it as a FITS file"
1749
+ )
1750
+
1751
+ # Add subparsers for different commands
1752
+ subparsers = parser.add_subparsers(dest="command", help="Command to execute")
1753
+
1754
+ # Subparser for the convert command
1755
+ convert_parser = subparsers.add_parser(
1756
+ "convert", help="Convert a FITS file to helioprojective coordinates"
1757
+ )
1758
+ convert_parser.add_argument("input_file", help="Path to the input FITS file")
1759
+ convert_parser.add_argument("output_file", help="Path to save the output FITS file")
1760
+ convert_parser.add_argument(
1761
+ "--stokes", default="I", help="Stokes parameter to use (default: I)"
1762
+ )
1763
+ convert_parser.add_argument(
1764
+ "--threshold", type=float, default=10, help="Threshold value (default: 10)"
1765
+ )
1766
+ convert_parser.add_argument(
1767
+ "--lat",
1768
+ default=None,
1769
+ help="Observer latitude (default: None (Example: -26:42:11.95, MWA))",
1770
+ )
1771
+ convert_parser.add_argument(
1772
+ "--long",
1773
+ default=None,
1774
+ help="Observer longitude (default: None (Example: 116:40:14.93, MWA))",
1775
+ )
1776
+ convert_parser.add_argument(
1777
+ "--height",
1778
+ type=float,
1779
+ default=None,
1780
+ help="Observer height in meters (default: None (Example: 377.8, MWA))",
1781
+ )
1782
+ convert_parser.add_argument(
1783
+ "--observatory", default="MWA", help="Observatory name (default: MWA)"
1784
+ )
1785
+ convert_parser.add_argument(
1786
+ "--no-overwrite",
1787
+ action="store_true",
1788
+ help="Do not overwrite the output file if it exists",
1789
+ )
1790
+
1791
+ # Subparser for the itrf command
1792
+ itrf_parser = subparsers.add_parser(
1793
+ "itrf", help="Convert ITRF coordinates to geodetic coordinates"
1794
+ )
1795
+ itrf_parser.add_argument("x", type=float, help="X coordinate in meters")
1796
+ itrf_parser.add_argument("y", type=float, help="Y coordinate in meters")
1797
+ itrf_parser.add_argument("z", type=float, help="Z coordinate in meters")
1798
+ itrf_parser.add_argument(
1799
+ "--scale",
1800
+ type=float,
1801
+ default=1.0,
1802
+ help="Scale factor to apply to the coordinates (default: 1.0)",
1803
+ )
1804
+
1805
+ # Subparser for the geodetic command
1806
+ geodetic_parser = subparsers.add_parser(
1807
+ "geodetic", help="Convert geodetic coordinates to ITRF coordinates"
1808
+ )
1809
+ geodetic_parser.add_argument(
1810
+ "lat", help="Latitude in the format DD:MM:SS.SS or decimal degrees"
1811
+ )
1812
+ geodetic_parser.add_argument(
1813
+ "lon", help="Longitude in the format DD:MM:SS.SS or decimal degrees"
1814
+ )
1815
+ geodetic_parser.add_argument(
1816
+ "height", type=float, help="Height in meters above the WGS84 ellipsoid"
1817
+ )
1818
+
1819
+ # Subparser for the test command
1820
+ test_parser = subparsers.add_parser(
1821
+ "test", help="Run tests for the helioprojective module"
1822
+ )
1823
+ test_parser.add_argument(
1824
+ "--extract-position",
1825
+ action="store_true",
1826
+ help="Test the extract_telescope_position function",
1827
+ )
1828
+
1829
+ # Parse the arguments
1830
+ args = parser.parse_args()
1831
+
1832
+ # Execute the appropriate command
1833
+ if args.command == "convert":
1834
+ success = convert_and_save_hpc(
1835
+ args.input_file,
1836
+ args.output_file,
1837
+ Stokes=args.stokes,
1838
+ thres=args.threshold,
1839
+ lat=args.lat,
1840
+ long=args.long,
1841
+ height=args.height,
1842
+ observatory=args.observatory,
1843
+ overwrite=not args.no_overwrite,
1844
+ )
1845
+
1846
+ if success:
1847
+ print("Conversion successful!")
1848
+ else:
1849
+ print("Conversion failed.")
1850
+ sys.exit(1)
1851
+ elif args.command == "itrf":
1852
+ lat, lon, height = itrf_to_geodetic(args.x, args.y, args.z, args.scale)
1853
+ if lat is not None:
1854
+ print(f"Latitude: {lat}")
1855
+ print(f"Longitude: {lon}")
1856
+ print(f"Height: {height} meters")
1857
+ print("\nUse these values in convert_to_hpc as:")
1858
+ print(f'lat="{lat}", long="{lon}", height={height}')
1859
+ else:
1860
+ print("ITRF conversion failed.")
1861
+ sys.exit(1)
1862
+ elif args.command == "geodetic":
1863
+ try:
1864
+ # Try to convert lat/lon to float if they are in decimal format
1865
+ try:
1866
+ lat = float(args.lat)
1867
+ except ValueError:
1868
+ lat = args.lat
1869
+
1870
+ try:
1871
+ lon = float(args.lon)
1872
+ except ValueError:
1873
+ lon = args.lon
1874
+
1875
+ x, y, z = geodetic_to_itrf(lat, lon, args.height)
1876
+ if x is not None:
1877
+ print(f"ITRF Coordinates:")
1878
+ print(f"X: {x:.3f} meters")
1879
+ print(f"Y: {y:.3f} meters")
1880
+ print(f"Z: {z:.3f} meters")
1881
+ else:
1882
+ print("Geodetic to ITRF conversion failed.")
1883
+ sys.exit(1)
1884
+ except Exception as e:
1885
+ print(f"Error: {str(e)}")
1886
+ sys.exit(1)
1887
+ elif args.command == "test":
1888
+ if args.extract_position:
1889
+ test_extract_telescope_position()
1890
+ else:
1891
+ print(
1892
+ "No test specified. Use --extract-position to test the extract_telescope_position function."
1893
+ )
1894
+ sys.exit(1)
1895
+ else:
1896
+ # Default behavior for backward compatibility
1897
+ if len(sys.argv) >= 3 and not sys.argv[1].startswith("-"):
1898
+ # Assume the first two arguments are input_file and output_file
1899
+ success = convert_and_save_hpc(
1900
+ sys.argv[1],
1901
+ sys.argv[2],
1902
+ # Parse any additional arguments
1903
+ **{
1904
+ k.lstrip("-").replace("-", "_"): v
1905
+ for k, v in zip(sys.argv[3::2], sys.argv[4::2])
1906
+ },
1907
+ )
1908
+
1909
+ if success:
1910
+ print("Conversion successful!")
1911
+ else:
1912
+ print("Conversion failed.")
1913
+ sys.exit(1)
1914
+ else:
1915
+ parser.print_help()
1916
+ sys.exit(1)