ecallistolib 0.2.1__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.
- ecallistolib/__init__.py +110 -0
- ecallistolib/combine.py +110 -0
- ecallistolib/crop.py +225 -0
- ecallistolib/download.py +147 -0
- ecallistolib/exceptions.py +38 -0
- ecallistolib/io.py +130 -0
- ecallistolib/models.py +44 -0
- ecallistolib/plotting.py +406 -0
- ecallistolib/processing.py +70 -0
- ecallistolib-0.2.1.dist-info/METADATA +833 -0
- ecallistolib-0.2.1.dist-info/RECORD +14 -0
- ecallistolib-0.2.1.dist-info/WHEEL +5 -0
- ecallistolib-0.2.1.dist-info/licenses/LICENSE +21 -0
- ecallistolib-0.2.1.dist-info/top_level.txt +1 -0
ecallistolib/io.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
e-callistolib: Tools for e-CALLISTO FITS dynamic spectra.
|
|
3
|
+
Version 0.2.1
|
|
4
|
+
Sahan S Liyanage (sahanslst@gmail.com)
|
|
5
|
+
Astronomical and Space Science Unit, University of Colombo, Sri Lanka.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional, Tuple
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from astropy.io import fits
|
|
17
|
+
|
|
18
|
+
from .exceptions import InvalidFilenameError, InvalidFITSError
|
|
19
|
+
from .models import DynamicSpectrum
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class CallistoFileParts:
|
|
24
|
+
station: str
|
|
25
|
+
date_yyyymmdd: str
|
|
26
|
+
time_hhmmss: str
|
|
27
|
+
focus: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_callisto_filename(path: str | Path) -> CallistoFileParts:
|
|
31
|
+
"""
|
|
32
|
+
Parse e-CALLISTO style filenames like:
|
|
33
|
+
STATION_YYYYMMDD_HHMMSS_FOCUS.fit.gz
|
|
34
|
+
|
|
35
|
+
Raises
|
|
36
|
+
------
|
|
37
|
+
InvalidFilenameError
|
|
38
|
+
If the filename doesn't match the expected format.
|
|
39
|
+
"""
|
|
40
|
+
base = Path(path).name
|
|
41
|
+
parts = base.split("_")
|
|
42
|
+
if len(parts) < 4:
|
|
43
|
+
raise InvalidFilenameError(f"Invalid CALLISTO filename format: {base}")
|
|
44
|
+
|
|
45
|
+
station = parts[0]
|
|
46
|
+
date_yyyymmdd = parts[1]
|
|
47
|
+
time_hhmmss = parts[2]
|
|
48
|
+
focus = parts[3].split(".")[0]
|
|
49
|
+
return CallistoFileParts(station, date_yyyymmdd, time_hhmmss, focus)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _try_read_ut_start_seconds(hdul: fits.HDUList) -> Optional[float]:
|
|
53
|
+
"""
|
|
54
|
+
Reads TIME-OBS from primary header if present and returns seconds since 00:00:00.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
hdr = hdul[0].header
|
|
58
|
+
hh, mm, ss = str(hdr["TIME-OBS"]).split(":")
|
|
59
|
+
return int(hh) * 3600 + int(mm) * 60 + float(ss)
|
|
60
|
+
except Exception:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def read_fits(path: str | Path) -> DynamicSpectrum:
|
|
65
|
+
"""
|
|
66
|
+
Read an e-CALLISTO FITS file (.fit or .fit.gz) into a DynamicSpectrum.
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
path : str or Path
|
|
71
|
+
Path to the FITS file.
|
|
72
|
+
|
|
73
|
+
Returns
|
|
74
|
+
-------
|
|
75
|
+
DynamicSpectrum
|
|
76
|
+
The loaded dynamic spectrum.
|
|
77
|
+
|
|
78
|
+
Raises
|
|
79
|
+
------
|
|
80
|
+
FileNotFoundError
|
|
81
|
+
If the file does not exist.
|
|
82
|
+
InvalidFITSError
|
|
83
|
+
If the file cannot be read or is not a valid e-CALLISTO FITS file.
|
|
84
|
+
"""
|
|
85
|
+
path = Path(path)
|
|
86
|
+
|
|
87
|
+
if not path.exists():
|
|
88
|
+
raise FileNotFoundError(f"FITS file not found: {path}")
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
with fits.open(path) as hdul:
|
|
92
|
+
if len(hdul) < 2:
|
|
93
|
+
raise InvalidFITSError(
|
|
94
|
+
f"Expected at least 2 HDUs in FITS file, got {len(hdul)}: {path}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if hdul[0].data is None:
|
|
98
|
+
raise InvalidFITSError(f"Primary HDU contains no data: {path}")
|
|
99
|
+
|
|
100
|
+
data = np.asarray(hdul[0].data, dtype=float)
|
|
101
|
+
|
|
102
|
+
# Check for frequency and time data in extension
|
|
103
|
+
try:
|
|
104
|
+
freqs = np.asarray(hdul[1].data["frequency"][0], dtype=float)
|
|
105
|
+
time_s = np.asarray(hdul[1].data["time"][0], dtype=float)
|
|
106
|
+
except (KeyError, IndexError) as e:
|
|
107
|
+
raise InvalidFITSError(
|
|
108
|
+
f"Missing frequency or time data in FITS extension: {path}"
|
|
109
|
+
) from e
|
|
110
|
+
|
|
111
|
+
ut_start_sec = _try_read_ut_start_seconds(hdul)
|
|
112
|
+
|
|
113
|
+
except OSError as e:
|
|
114
|
+
raise InvalidFITSError(f"Failed to open FITS file: {path}") from e
|
|
115
|
+
|
|
116
|
+
meta = {"ut_start_sec": ut_start_sec}
|
|
117
|
+
try:
|
|
118
|
+
parts = parse_callisto_filename(path)
|
|
119
|
+
meta |= {
|
|
120
|
+
"station": parts.station,
|
|
121
|
+
"date": parts.date_yyyymmdd,
|
|
122
|
+
"time": parts.time_hhmmss,
|
|
123
|
+
"focus": parts.focus,
|
|
124
|
+
}
|
|
125
|
+
except InvalidFilenameError:
|
|
126
|
+
# Filename parsing is optional, continue without metadata
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
return DynamicSpectrum(data=data, freqs_mhz=freqs, time_s=time_s, source=path, meta=meta)
|
|
130
|
+
|
ecallistolib/models.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
e-callistolib: Tools for e-CALLISTO FITS dynamic spectra.
|
|
3
|
+
Version 0.2.1
|
|
4
|
+
Sahan S Liyanage (sahanslst@gmail.com)
|
|
5
|
+
Astronomical and Space Science Unit, University of Colombo, Sri Lanka.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Mapping, Optional
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class DynamicSpectrum:
|
|
19
|
+
"""
|
|
20
|
+
Represents an e-CALLISTO dynamic spectrum.
|
|
21
|
+
|
|
22
|
+
data shape: (n_freq, n_time)
|
|
23
|
+
freqs_mhz shape: (n_freq,)
|
|
24
|
+
time_s shape: (n_time,)
|
|
25
|
+
"""
|
|
26
|
+
data: np.ndarray
|
|
27
|
+
freqs_mhz: np.ndarray
|
|
28
|
+
time_s: np.ndarray
|
|
29
|
+
source: Optional[Path] = None
|
|
30
|
+
meta: Mapping[str, Any] = field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
def copy_with(self, **changes: Any) -> "DynamicSpectrum":
|
|
33
|
+
"""Return a new DynamicSpectrum with specified fields replaced."""
|
|
34
|
+
return DynamicSpectrum(
|
|
35
|
+
data=changes.get("data", self.data),
|
|
36
|
+
freqs_mhz=changes.get("freqs_mhz", self.freqs_mhz),
|
|
37
|
+
time_s=changes.get("time_s", self.time_s),
|
|
38
|
+
source=changes.get("source", self.source),
|
|
39
|
+
meta=changes.get("meta", dict(self.meta)),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def shape(self) -> tuple[int, int]:
|
|
44
|
+
return int(self.data.shape[0]), int(self.data.shape[1])
|
ecallistolib/plotting.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""
|
|
2
|
+
e-callistolib: Tools for e-CALLISTO FITS dynamic spectra.
|
|
3
|
+
Version 0.2.1
|
|
4
|
+
Sahan S Liyanage (sahanslst@gmail.com)
|
|
5
|
+
Astronomical and Space Science Unit, University of Colombo, Sri Lanka.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import TYPE_CHECKING, Literal, Optional
|
|
12
|
+
|
|
13
|
+
import matplotlib.pyplot as plt
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from .models import DynamicSpectrum
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from matplotlib.axes import Axes
|
|
20
|
+
from matplotlib.figure import Figure
|
|
21
|
+
from matplotlib.image import AxesImage
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class TimeAxisConverter:
|
|
26
|
+
"""
|
|
27
|
+
Convert between elapsed seconds and UT (Universal Time) strings.
|
|
28
|
+
|
|
29
|
+
This class helps convert time values between the seconds-from-start format
|
|
30
|
+
used internally by DynamicSpectrum and human-readable UT time strings.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
ut_start_sec : float
|
|
35
|
+
UT observation start time in seconds since midnight (00:00:00).
|
|
36
|
+
|
|
37
|
+
Example
|
|
38
|
+
-------
|
|
39
|
+
>>> converter = TimeAxisConverter(ut_start_sec=43200.0) # 12:00:00
|
|
40
|
+
>>> converter.seconds_to_ut(100)
|
|
41
|
+
'12:01:40'
|
|
42
|
+
>>> converter.ut_to_seconds("12:01:40")
|
|
43
|
+
100.0
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
ut_start_sec: float
|
|
47
|
+
|
|
48
|
+
def seconds_to_ut(self, seconds: float) -> str:
|
|
49
|
+
"""
|
|
50
|
+
Convert elapsed seconds to UT time string (HH:MM:SS).
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
seconds : float
|
|
55
|
+
Elapsed seconds from observation start.
|
|
56
|
+
|
|
57
|
+
Returns
|
|
58
|
+
-------
|
|
59
|
+
str
|
|
60
|
+
UT time string in HH:MM:SS format.
|
|
61
|
+
"""
|
|
62
|
+
total_sec = self.ut_start_sec + seconds
|
|
63
|
+
hours = int(total_sec // 3600) % 24
|
|
64
|
+
minutes = int((total_sec % 3600) // 60)
|
|
65
|
+
secs = int(total_sec % 60)
|
|
66
|
+
return f"{hours:02d}:{minutes:02d}"
|
|
67
|
+
|
|
68
|
+
def ut_to_seconds(self, ut_str: str) -> float:
|
|
69
|
+
"""
|
|
70
|
+
Convert UT time string (HH:MM:SS) to elapsed seconds.
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
ut_str : str
|
|
75
|
+
UT time string in HH:MM:SS or HH:MM:SS.sss format.
|
|
76
|
+
|
|
77
|
+
Returns
|
|
78
|
+
-------
|
|
79
|
+
float
|
|
80
|
+
Elapsed seconds from observation start.
|
|
81
|
+
"""
|
|
82
|
+
parts = ut_str.split(":")
|
|
83
|
+
hh, mm = int(parts[0]), int(parts[1])
|
|
84
|
+
ss = float(parts[2]) if len(parts) > 2 else 0.0
|
|
85
|
+
total_sec = hh * 3600 + mm * 60 + ss
|
|
86
|
+
return total_sec - self.ut_start_sec
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_dynamic_spectrum(cls, ds: DynamicSpectrum) -> "TimeAxisConverter":
|
|
90
|
+
"""
|
|
91
|
+
Create a TimeAxisConverter from a DynamicSpectrum's metadata.
|
|
92
|
+
|
|
93
|
+
Parameters
|
|
94
|
+
----------
|
|
95
|
+
ds : DynamicSpectrum
|
|
96
|
+
The dynamic spectrum containing ut_start_sec in its metadata.
|
|
97
|
+
|
|
98
|
+
Returns
|
|
99
|
+
-------
|
|
100
|
+
TimeAxisConverter
|
|
101
|
+
Converter initialized with the spectrum's UT start time.
|
|
102
|
+
|
|
103
|
+
Raises
|
|
104
|
+
------
|
|
105
|
+
ValueError
|
|
106
|
+
If ut_start_sec is not available in the spectrum's metadata.
|
|
107
|
+
"""
|
|
108
|
+
ut_start = ds.meta.get("ut_start_sec")
|
|
109
|
+
if ut_start is None:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
"DynamicSpectrum does not have 'ut_start_sec' in metadata. "
|
|
112
|
+
"This is typically read from the TIME-OBS header in FITS files."
|
|
113
|
+
)
|
|
114
|
+
return cls(ut_start_sec=float(ut_start))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _compute_extent(
|
|
118
|
+
ds: DynamicSpectrum,
|
|
119
|
+
time_format: Literal["seconds", "ut"],
|
|
120
|
+
) -> tuple[list[float], Optional[TimeAxisConverter]]:
|
|
121
|
+
"""Compute imshow extent and optional time converter."""
|
|
122
|
+
converter = None
|
|
123
|
+
if time_format == "ut":
|
|
124
|
+
converter = TimeAxisConverter.from_dynamic_spectrum(ds)
|
|
125
|
+
t_start = ds.time_s[0] + converter.ut_start_sec
|
|
126
|
+
t_end = ds.time_s[-1] + converter.ut_start_sec
|
|
127
|
+
else:
|
|
128
|
+
t_start = float(ds.time_s[0])
|
|
129
|
+
t_end = float(ds.time_s[-1])
|
|
130
|
+
|
|
131
|
+
extent = [t_start, t_end, float(ds.freqs_mhz[-1]), float(ds.freqs_mhz[0])]
|
|
132
|
+
return extent, converter
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _format_time_axis(
|
|
136
|
+
ax: Axes,
|
|
137
|
+
converter: Optional[TimeAxisConverter],
|
|
138
|
+
time_format: Literal["seconds", "ut"],
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Format the time axis labels."""
|
|
141
|
+
if time_format == "ut" and converter is not None:
|
|
142
|
+
ax.set_xlabel("Time [UT]")
|
|
143
|
+
# Format x-tick labels as UT times
|
|
144
|
+
from matplotlib.ticker import FuncFormatter
|
|
145
|
+
|
|
146
|
+
def fmt(x, pos):
|
|
147
|
+
return converter.seconds_to_ut(x - converter.ut_start_sec)
|
|
148
|
+
|
|
149
|
+
ax.xaxis.set_major_formatter(FuncFormatter(fmt))
|
|
150
|
+
else:
|
|
151
|
+
ax.set_xlabel("Time [s]")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _get_filename_title(ds: DynamicSpectrum, suffix: str) -> str:
|
|
155
|
+
"""Generate plot title from DynamicSpectrum source filename."""
|
|
156
|
+
if ds.source is not None:
|
|
157
|
+
filename = ds.source.stem # Get filename without extension
|
|
158
|
+
return f"{filename}_{suffix}"
|
|
159
|
+
return suffix
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def plot_raw_spectrum(
|
|
163
|
+
ds: DynamicSpectrum,
|
|
164
|
+
title: str | None = None,
|
|
165
|
+
cmap: str = "viridis",
|
|
166
|
+
figsize: tuple[float, float] | None = None,
|
|
167
|
+
vmin: float | None = None,
|
|
168
|
+
vmax: float | None = None,
|
|
169
|
+
ax: Optional[plt.Axes] = None,
|
|
170
|
+
show_colorbar: bool = True,
|
|
171
|
+
time_format: Literal["seconds", "ut"] = "seconds",
|
|
172
|
+
**imshow_kwargs,
|
|
173
|
+
) -> tuple["Figure", "Axes", "AxesImage"]:
|
|
174
|
+
"""
|
|
175
|
+
Plot a raw DynamicSpectrum without any processing.
|
|
176
|
+
|
|
177
|
+
Parameters
|
|
178
|
+
----------
|
|
179
|
+
ds : DynamicSpectrum
|
|
180
|
+
The dynamic spectrum to plot.
|
|
181
|
+
title : str
|
|
182
|
+
Plot title.
|
|
183
|
+
cmap : str
|
|
184
|
+
Matplotlib colormap name.
|
|
185
|
+
figsize : tuple[float, float] | None
|
|
186
|
+
Figure size as (width, height) in inches. Ignored if ax is provided.
|
|
187
|
+
vmin : float | None
|
|
188
|
+
Minimum value for colormap normalization.
|
|
189
|
+
vmax : float | None
|
|
190
|
+
Maximum value for colormap normalization.
|
|
191
|
+
ax : plt.Axes | None
|
|
192
|
+
Existing axes to plot on. If None, creates a new figure.
|
|
193
|
+
show_colorbar : bool
|
|
194
|
+
Whether to show a colorbar.
|
|
195
|
+
time_format : {"seconds", "ut"}
|
|
196
|
+
Format for the time axis. "seconds" shows elapsed seconds,
|
|
197
|
+
"ut" shows Universal Time (requires ut_start_sec in metadata).
|
|
198
|
+
**imshow_kwargs
|
|
199
|
+
Additional keyword arguments passed to matplotlib's imshow().
|
|
200
|
+
|
|
201
|
+
Returns
|
|
202
|
+
-------
|
|
203
|
+
tuple[Figure, Axes, AxesImage]
|
|
204
|
+
The figure, axes, and image objects.
|
|
205
|
+
|
|
206
|
+
Example
|
|
207
|
+
-------
|
|
208
|
+
>>> ds = read_fits("spectrum.fit.gz")
|
|
209
|
+
>>> fig, ax, im = plot_raw_spectrum(ds, figsize=(12, 6), cmap="plasma")
|
|
210
|
+
"""
|
|
211
|
+
if ax is None:
|
|
212
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
213
|
+
else:
|
|
214
|
+
fig = ax.figure
|
|
215
|
+
|
|
216
|
+
extent, converter = _compute_extent(ds, time_format)
|
|
217
|
+
|
|
218
|
+
im = ax.imshow(
|
|
219
|
+
ds.data,
|
|
220
|
+
aspect="auto",
|
|
221
|
+
extent=extent,
|
|
222
|
+
cmap=cmap,
|
|
223
|
+
vmin=vmin,
|
|
224
|
+
vmax=vmax,
|
|
225
|
+
**imshow_kwargs,
|
|
226
|
+
)
|
|
227
|
+
# Use filename-based title if not provided
|
|
228
|
+
if title is None:
|
|
229
|
+
title = _get_filename_title(ds, "raw")
|
|
230
|
+
ax.set_title(title)
|
|
231
|
+
_format_time_axis(ax, converter, time_format)
|
|
232
|
+
ax.set_ylabel("Frequency [MHz]")
|
|
233
|
+
|
|
234
|
+
if show_colorbar:
|
|
235
|
+
cbar = fig.colorbar(im, ax=ax)
|
|
236
|
+
cbar.set_label("Intensity [DN]")
|
|
237
|
+
|
|
238
|
+
return fig, ax, im
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def plot_dynamic_spectrum(
|
|
242
|
+
ds: DynamicSpectrum,
|
|
243
|
+
title: str | None = None,
|
|
244
|
+
cmap: str = "inferno",
|
|
245
|
+
figsize: tuple[float, float] | None = None,
|
|
246
|
+
vmin: float | None = None,
|
|
247
|
+
vmax: float | None = None,
|
|
248
|
+
ax: Optional[plt.Axes] = None,
|
|
249
|
+
show_colorbar: bool = True,
|
|
250
|
+
time_format: Literal["seconds", "ut"] = "seconds",
|
|
251
|
+
**imshow_kwargs,
|
|
252
|
+
) -> tuple["Figure", "Axes", "AxesImage"]:
|
|
253
|
+
"""
|
|
254
|
+
Plot a DynamicSpectrum using matplotlib with full customization.
|
|
255
|
+
|
|
256
|
+
This is the main plotting function with support for all matplotlib
|
|
257
|
+
imshow parameters including colormap clipping (vmin/vmax), figure size,
|
|
258
|
+
and custom time axis formats.
|
|
259
|
+
|
|
260
|
+
Parameters
|
|
261
|
+
----------
|
|
262
|
+
ds : DynamicSpectrum
|
|
263
|
+
The dynamic spectrum to plot.
|
|
264
|
+
title : str
|
|
265
|
+
Plot title.
|
|
266
|
+
cmap : str
|
|
267
|
+
Matplotlib colormap name (e.g., "inferno", "viridis", "magma", "plasma").
|
|
268
|
+
figsize : tuple[float, float] | None
|
|
269
|
+
Figure size as (width, height) in inches. Ignored if ax is provided.
|
|
270
|
+
vmin : float | None
|
|
271
|
+
Minimum value for colormap normalization (clipping lower bound).
|
|
272
|
+
vmax : float | None
|
|
273
|
+
Maximum value for colormap normalization (clipping upper bound).
|
|
274
|
+
ax : plt.Axes | None
|
|
275
|
+
Existing axes to plot on. If None, creates a new figure.
|
|
276
|
+
show_colorbar : bool
|
|
277
|
+
Whether to show a colorbar.
|
|
278
|
+
time_format : {"seconds", "ut"}
|
|
279
|
+
Format for the time axis. "seconds" shows elapsed seconds,
|
|
280
|
+
"ut" shows Universal Time (requires ut_start_sec in metadata).
|
|
281
|
+
**imshow_kwargs
|
|
282
|
+
Additional keyword arguments passed to matplotlib's imshow().
|
|
283
|
+
Common options include:
|
|
284
|
+
- interpolation: str ("nearest", "bilinear", "bicubic", etc.)
|
|
285
|
+
- origin: str ("upper", "lower")
|
|
286
|
+
- alpha: float (transparency)
|
|
287
|
+
- norm: matplotlib.colors.Normalize (custom normalization)
|
|
288
|
+
|
|
289
|
+
Returns
|
|
290
|
+
-------
|
|
291
|
+
tuple[Figure, Axes, AxesImage]
|
|
292
|
+
The figure, axes, and image objects.
|
|
293
|
+
|
|
294
|
+
Example
|
|
295
|
+
-------
|
|
296
|
+
>>> ds = read_fits("spectrum.fit.gz")
|
|
297
|
+
>>> ds_reduced = noise_reduce_mean_clip(ds)
|
|
298
|
+
>>> fig, ax, im = plot_dynamic_spectrum(
|
|
299
|
+
... ds_reduced,
|
|
300
|
+
... title="Noise Reduced",
|
|
301
|
+
... vmin=-5, vmax=20,
|
|
302
|
+
... figsize=(12, 6),
|
|
303
|
+
... cmap="magma"
|
|
304
|
+
... )
|
|
305
|
+
"""
|
|
306
|
+
if ax is None:
|
|
307
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
308
|
+
else:
|
|
309
|
+
fig = ax.figure
|
|
310
|
+
|
|
311
|
+
extent, converter = _compute_extent(ds, time_format)
|
|
312
|
+
|
|
313
|
+
im = ax.imshow(
|
|
314
|
+
ds.data,
|
|
315
|
+
aspect="auto",
|
|
316
|
+
extent=extent,
|
|
317
|
+
cmap=cmap,
|
|
318
|
+
vmin=vmin,
|
|
319
|
+
vmax=vmax,
|
|
320
|
+
**imshow_kwargs,
|
|
321
|
+
)
|
|
322
|
+
# Use filename-based title if not provided
|
|
323
|
+
if title is None:
|
|
324
|
+
title = _get_filename_title(ds, "dynamic_spectrum")
|
|
325
|
+
ax.set_title(title)
|
|
326
|
+
_format_time_axis(ax, converter, time_format)
|
|
327
|
+
ax.set_ylabel("Frequency [MHz]")
|
|
328
|
+
|
|
329
|
+
if show_colorbar:
|
|
330
|
+
cbar = fig.colorbar(im, ax=ax)
|
|
331
|
+
cbar.set_label("Intensity [DN]")
|
|
332
|
+
|
|
333
|
+
return fig, ax, im
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def plot_background_subtracted(
|
|
337
|
+
ds: DynamicSpectrum,
|
|
338
|
+
title: str | None = None,
|
|
339
|
+
cmap: str = "jet",
|
|
340
|
+
figsize: tuple[float, float] | None = None,
|
|
341
|
+
vmin: float | None = None,
|
|
342
|
+
vmax: float | None = None,
|
|
343
|
+
ax: Optional[plt.Axes] = None,
|
|
344
|
+
show_colorbar: bool = True,
|
|
345
|
+
time_format: Literal["seconds", "ut"] = "seconds",
|
|
346
|
+
**imshow_kwargs,
|
|
347
|
+
) -> tuple["Figure", "Axes", "AxesImage"]:
|
|
348
|
+
"""
|
|
349
|
+
Plot a DynamicSpectrum after background subtraction (before clipping).
|
|
350
|
+
|
|
351
|
+
This is a convenience function that applies background subtraction
|
|
352
|
+
(mean removal per frequency channel) and plots the result. This shows
|
|
353
|
+
the intermediate step before clipping is applied in noise reduction.
|
|
354
|
+
|
|
355
|
+
Parameters
|
|
356
|
+
----------
|
|
357
|
+
ds : DynamicSpectrum
|
|
358
|
+
The raw dynamic spectrum (will be background-subtracted internally).
|
|
359
|
+
title : str
|
|
360
|
+
Plot title.
|
|
361
|
+
cmap : str
|
|
362
|
+
Matplotlib colormap name. Default is "RdBu_r" (diverging colormap)
|
|
363
|
+
which works well for showing positive/negative deviations.
|
|
364
|
+
figsize : tuple[float, float] | None
|
|
365
|
+
Figure size as (width, height) in inches.
|
|
366
|
+
vmin : float | None
|
|
367
|
+
Minimum value for colormap normalization.
|
|
368
|
+
vmax : float | None
|
|
369
|
+
Maximum value for colormap normalization.
|
|
370
|
+
ax : plt.Axes | None
|
|
371
|
+
Existing axes to plot on. If None, creates a new figure.
|
|
372
|
+
show_colorbar : bool
|
|
373
|
+
Whether to show a colorbar.
|
|
374
|
+
time_format : {"seconds", "ut"}
|
|
375
|
+
Format for the time axis.
|
|
376
|
+
**imshow_kwargs
|
|
377
|
+
Additional keyword arguments passed to matplotlib's imshow().
|
|
378
|
+
|
|
379
|
+
Returns
|
|
380
|
+
-------
|
|
381
|
+
tuple[Figure, Axes, AxesImage]
|
|
382
|
+
The figure, axes, and image objects.
|
|
383
|
+
|
|
384
|
+
Example
|
|
385
|
+
-------
|
|
386
|
+
>>> ds = read_fits("spectrum.fit.gz")
|
|
387
|
+
>>> fig, ax, im = plot_background_subtracted(ds, vmin=-10, vmax=30)
|
|
388
|
+
"""
|
|
389
|
+
from .processing import background_subtract
|
|
390
|
+
|
|
391
|
+
ds_bg = background_subtract(ds)
|
|
392
|
+
# Use filename-based title if not provided
|
|
393
|
+
if title is None:
|
|
394
|
+
title = _get_filename_title(ds, "background_subtracted")
|
|
395
|
+
return plot_dynamic_spectrum(
|
|
396
|
+
ds_bg,
|
|
397
|
+
title=title,
|
|
398
|
+
cmap=cmap,
|
|
399
|
+
figsize=figsize,
|
|
400
|
+
vmin=vmin,
|
|
401
|
+
vmax=vmax,
|
|
402
|
+
ax=ax,
|
|
403
|
+
show_colorbar=show_colorbar,
|
|
404
|
+
time_format=time_format,
|
|
405
|
+
**imshow_kwargs,
|
|
406
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
e-callistolib: Tools for e-CALLISTO FITS dynamic spectra.
|
|
3
|
+
Version 0.2.1
|
|
4
|
+
Sahan S Liyanage (sahanslst@gmail.com)
|
|
5
|
+
Astronomical and Space Science Unit, University of Colombo, Sri Lanka.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from .models import DynamicSpectrum
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def noise_reduce_mean_clip(
|
|
16
|
+
ds: DynamicSpectrum,
|
|
17
|
+
clip_low: float = -5.0,
|
|
18
|
+
clip_high: float = 20.0,
|
|
19
|
+
scale: float | None = (2500.0 / 255.0 / 25.4),
|
|
20
|
+
) -> DynamicSpectrum:
|
|
21
|
+
"""
|
|
22
|
+
Basic noise reduction used in your GUI:
|
|
23
|
+
1) subtract mean over time for each frequency channel
|
|
24
|
+
2) clip to [clip_low, clip_high]
|
|
25
|
+
3) optional scaling
|
|
26
|
+
"""
|
|
27
|
+
data = np.array(ds.data, copy=True, dtype=float)
|
|
28
|
+
data = data - data.mean(axis=1, keepdims=True)
|
|
29
|
+
data = np.clip(data, clip_low, clip_high)
|
|
30
|
+
if scale is not None:
|
|
31
|
+
data = data * float(scale)
|
|
32
|
+
|
|
33
|
+
meta = dict(ds.meta)
|
|
34
|
+
meta["noise_reduction"] = {
|
|
35
|
+
"method": "mean_subtract_clip",
|
|
36
|
+
"clip_low": clip_low,
|
|
37
|
+
"clip_high": clip_high,
|
|
38
|
+
"scale": scale,
|
|
39
|
+
}
|
|
40
|
+
return ds.copy_with(data=data, meta=meta)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def background_subtract(ds: DynamicSpectrum) -> DynamicSpectrum:
|
|
44
|
+
"""
|
|
45
|
+
Subtract mean over time for each frequency channel (background subtraction only).
|
|
46
|
+
|
|
47
|
+
This is the first step of noise reduction without clipping, useful for
|
|
48
|
+
visualizing the intermediate result before applying clipping.
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
ds : DynamicSpectrum
|
|
53
|
+
Input dynamic spectrum.
|
|
54
|
+
|
|
55
|
+
Returns
|
|
56
|
+
-------
|
|
57
|
+
DynamicSpectrum
|
|
58
|
+
New spectrum with background (mean per frequency) subtracted.
|
|
59
|
+
|
|
60
|
+
Example
|
|
61
|
+
-------
|
|
62
|
+
>>> ds_bg = background_subtract(ds)
|
|
63
|
+
>>> plot_dynamic_spectrum(ds_bg, title="Background Subtracted")
|
|
64
|
+
"""
|
|
65
|
+
data = np.array(ds.data, copy=True, dtype=float)
|
|
66
|
+
data = data - data.mean(axis=1, keepdims=True)
|
|
67
|
+
|
|
68
|
+
meta = dict(ds.meta)
|
|
69
|
+
meta["processing"] = {"method": "background_subtract"}
|
|
70
|
+
return ds.copy_with(data=data, meta=meta)
|