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.
- solar_radio_image_viewer/__init__.py +12 -0
- solar_radio_image_viewer/assets/add_tab_default.png +0 -0
- solar_radio_image_viewer/assets/add_tab_default_light.png +0 -0
- solar_radio_image_viewer/assets/add_tab_hover.png +0 -0
- solar_radio_image_viewer/assets/add_tab_hover_light.png +0 -0
- solar_radio_image_viewer/assets/browse.png +0 -0
- solar_radio_image_viewer/assets/browse_light.png +0 -0
- solar_radio_image_viewer/assets/close_tab_default.png +0 -0
- solar_radio_image_viewer/assets/close_tab_default_light.png +0 -0
- solar_radio_image_viewer/assets/close_tab_hover.png +0 -0
- solar_radio_image_viewer/assets/close_tab_hover_light.png +0 -0
- solar_radio_image_viewer/assets/ellipse_selection.png +0 -0
- solar_radio_image_viewer/assets/ellipse_selection_light.png +0 -0
- solar_radio_image_viewer/assets/icons8-ellipse-90.png +0 -0
- solar_radio_image_viewer/assets/icons8-ellipse-90_light.png +0 -0
- solar_radio_image_viewer/assets/icons8-info-90.png +0 -0
- solar_radio_image_viewer/assets/icons8-info-90_light.png +0 -0
- solar_radio_image_viewer/assets/profile.png +0 -0
- solar_radio_image_viewer/assets/profile_light.png +0 -0
- solar_radio_image_viewer/assets/rectangle_selection.png +0 -0
- solar_radio_image_viewer/assets/rectangle_selection_light.png +0 -0
- solar_radio_image_viewer/assets/reset.png +0 -0
- solar_radio_image_viewer/assets/reset_light.png +0 -0
- solar_radio_image_viewer/assets/ruler.png +0 -0
- solar_radio_image_viewer/assets/ruler_light.png +0 -0
- solar_radio_image_viewer/assets/search.png +0 -0
- solar_radio_image_viewer/assets/search_light.png +0 -0
- solar_radio_image_viewer/assets/settings.png +0 -0
- solar_radio_image_viewer/assets/settings_light.png +0 -0
- solar_radio_image_viewer/assets/splash.fits +0 -0
- solar_radio_image_viewer/assets/zoom_60arcmin.png +0 -0
- solar_radio_image_viewer/assets/zoom_60arcmin_light.png +0 -0
- solar_radio_image_viewer/assets/zoom_in.png +0 -0
- solar_radio_image_viewer/assets/zoom_in_light.png +0 -0
- solar_radio_image_viewer/assets/zoom_out.png +0 -0
- solar_radio_image_viewer/assets/zoom_out_light.png +0 -0
- solar_radio_image_viewer/create_video.py +1345 -0
- solar_radio_image_viewer/dialogs.py +2665 -0
- solar_radio_image_viewer/from_simpl/__init__.py +184 -0
- solar_radio_image_viewer/from_simpl/caltable_visualizer.py +1001 -0
- solar_radio_image_viewer/from_simpl/dynamic_spectra_dialog.py +332 -0
- solar_radio_image_viewer/from_simpl/make_dynamic_spectra.py +351 -0
- solar_radio_image_viewer/from_simpl/pipeline_logger_gui.py +1232 -0
- solar_radio_image_viewer/from_simpl/simpl_theme.py +352 -0
- solar_radio_image_viewer/from_simpl/utils.py +984 -0
- solar_radio_image_viewer/from_simpl/view_dynamic_spectra_GUI.py +1975 -0
- solar_radio_image_viewer/helioprojective.py +1916 -0
- solar_radio_image_viewer/helioprojective_viewer.py +817 -0
- solar_radio_image_viewer/helioviewer_browser.py +1514 -0
- solar_radio_image_viewer/main.py +148 -0
- solar_radio_image_viewer/move_phasecenter.py +1269 -0
- solar_radio_image_viewer/napari_viewer.py +368 -0
- solar_radio_image_viewer/noaa_events/__init__.py +32 -0
- solar_radio_image_viewer/noaa_events/noaa_events.py +430 -0
- solar_radio_image_viewer/noaa_events/noaa_events_gui.py +1922 -0
- solar_radio_image_viewer/norms.py +293 -0
- solar_radio_image_viewer/radio_data_downloader/__init__.py +25 -0
- solar_radio_image_viewer/radio_data_downloader/radio_data_downloader.py +756 -0
- solar_radio_image_viewer/radio_data_downloader/radio_data_downloader_gui.py +528 -0
- solar_radio_image_viewer/searchable_combobox.py +220 -0
- solar_radio_image_viewer/solar_context/__init__.py +41 -0
- solar_radio_image_viewer/solar_context/active_regions.py +371 -0
- solar_radio_image_viewer/solar_context/cme_alerts.py +234 -0
- solar_radio_image_viewer/solar_context/context_images.py +297 -0
- solar_radio_image_viewer/solar_context/realtime_data.py +528 -0
- solar_radio_image_viewer/solar_data_downloader/__init__.py +35 -0
- solar_radio_image_viewer/solar_data_downloader/solar_data_downloader.py +1667 -0
- solar_radio_image_viewer/solar_data_downloader/solar_data_downloader_cli.py +901 -0
- solar_radio_image_viewer/solar_data_downloader/solar_data_downloader_gui.py +1210 -0
- solar_radio_image_viewer/styles.py +643 -0
- solar_radio_image_viewer/utils/__init__.py +32 -0
- solar_radio_image_viewer/utils/rate_limiter.py +255 -0
- solar_radio_image_viewer/utils.py +952 -0
- solar_radio_image_viewer/video_dialog.py +2629 -0
- solar_radio_image_viewer/video_utils.py +656 -0
- solar_radio_image_viewer/viewer.py +11174 -0
- solarviewer-1.0.2.dist-info/METADATA +343 -0
- solarviewer-1.0.2.dist-info/RECORD +82 -0
- solarviewer-1.0.2.dist-info/WHEEL +5 -0
- solarviewer-1.0.2.dist-info/entry_points.txt +8 -0
- solarviewer-1.0.2.dist-info/licenses/LICENSE +21 -0
- 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)
|