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/__init__.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
|
|
2
|
+
"""
|
|
3
|
+
e-callistolib: Tools for e-CALLISTO FITS dynamic spectra.
|
|
4
|
+
Version 0.2.1
|
|
5
|
+
Sahan S Liyanage (sahanslst@gmail.com)
|
|
6
|
+
Astronomical and Space Science Unit, University of Colombo, Sri Lanka.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
11
|
+
|
|
12
|
+
from .exceptions import (
|
|
13
|
+
CombineError,
|
|
14
|
+
CropError,
|
|
15
|
+
DownloadError,
|
|
16
|
+
ECallistoError,
|
|
17
|
+
InvalidFilenameError,
|
|
18
|
+
InvalidFITSError,
|
|
19
|
+
)
|
|
20
|
+
from .io import CallistoFileParts, parse_callisto_filename, read_fits
|
|
21
|
+
from .models import DynamicSpectrum
|
|
22
|
+
from .processing import noise_reduce_mean_clip
|
|
23
|
+
from .crop import crop, crop_frequency, crop_time, slice_by_index
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
__version__ = version("ecallistolib")
|
|
27
|
+
except PackageNotFoundError:
|
|
28
|
+
__version__ = "0.0.0"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
# Version
|
|
32
|
+
"__version__",
|
|
33
|
+
# Core
|
|
34
|
+
"DynamicSpectrum",
|
|
35
|
+
"CallistoFileParts",
|
|
36
|
+
# I/O
|
|
37
|
+
"parse_callisto_filename",
|
|
38
|
+
"read_fits",
|
|
39
|
+
# Processing
|
|
40
|
+
"noise_reduce_mean_clip",
|
|
41
|
+
# Cropping
|
|
42
|
+
"crop",
|
|
43
|
+
"crop_frequency",
|
|
44
|
+
"crop_time",
|
|
45
|
+
"slice_by_index",
|
|
46
|
+
# Exceptions
|
|
47
|
+
"ECallistoError",
|
|
48
|
+
"InvalidFITSError",
|
|
49
|
+
"InvalidFilenameError",
|
|
50
|
+
"DownloadError",
|
|
51
|
+
"CombineError",
|
|
52
|
+
"CropError",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def __getattr__(name: str):
|
|
57
|
+
"""Lazy imports for optional dependencies."""
|
|
58
|
+
if name in {
|
|
59
|
+
"combine_time",
|
|
60
|
+
"combine_frequency",
|
|
61
|
+
"can_combine_time",
|
|
62
|
+
"can_combine_frequency",
|
|
63
|
+
}:
|
|
64
|
+
from .combine import (
|
|
65
|
+
can_combine_frequency,
|
|
66
|
+
can_combine_time,
|
|
67
|
+
combine_frequency,
|
|
68
|
+
combine_time,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
"can_combine_frequency": can_combine_frequency,
|
|
73
|
+
"combine_frequency": combine_frequency,
|
|
74
|
+
"can_combine_time": can_combine_time,
|
|
75
|
+
"combine_time": combine_time,
|
|
76
|
+
}[name]
|
|
77
|
+
|
|
78
|
+
if name in {"list_remote_fits", "download_files"}:
|
|
79
|
+
from .download import download_files, list_remote_fits
|
|
80
|
+
|
|
81
|
+
return {"list_remote_fits": list_remote_fits, "download_files": download_files}[
|
|
82
|
+
name
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
if name in {
|
|
86
|
+
"plot_dynamic_spectrum",
|
|
87
|
+
"plot_raw_spectrum",
|
|
88
|
+
"plot_background_subtracted",
|
|
89
|
+
"TimeAxisConverter",
|
|
90
|
+
}:
|
|
91
|
+
from .plotting import (
|
|
92
|
+
TimeAxisConverter,
|
|
93
|
+
plot_background_subtracted,
|
|
94
|
+
plot_dynamic_spectrum,
|
|
95
|
+
plot_raw_spectrum,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
"plot_dynamic_spectrum": plot_dynamic_spectrum,
|
|
100
|
+
"plot_raw_spectrum": plot_raw_spectrum,
|
|
101
|
+
"plot_background_subtracted": plot_background_subtracted,
|
|
102
|
+
"TimeAxisConverter": TimeAxisConverter,
|
|
103
|
+
}[name]
|
|
104
|
+
|
|
105
|
+
if name == "background_subtract":
|
|
106
|
+
from .processing import background_subtract
|
|
107
|
+
|
|
108
|
+
return background_subtract
|
|
109
|
+
|
|
110
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
ecallistolib/combine.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
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 pathlib import Path
|
|
11
|
+
from typing import Iterable
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from .io import parse_callisto_filename, read_fits
|
|
16
|
+
from .models import DynamicSpectrum
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def can_combine_frequency(path1: str | Path, path2: str | Path, time_atol: float = 0.01) -> bool:
|
|
20
|
+
"""
|
|
21
|
+
True if:
|
|
22
|
+
- same station/date/time
|
|
23
|
+
- different focus (01 vs 02)
|
|
24
|
+
- time axes match within tolerance
|
|
25
|
+
"""
|
|
26
|
+
p1 = parse_callisto_filename(path1)
|
|
27
|
+
p2 = parse_callisto_filename(path2)
|
|
28
|
+
|
|
29
|
+
if (p1.station != p2.station) or (p1.date_yyyymmdd != p2.date_yyyymmdd) or (p1.time_hhmmss != p2.time_hhmmss):
|
|
30
|
+
return False
|
|
31
|
+
if p1.focus == p2.focus:
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
ds1 = read_fits(path1)
|
|
35
|
+
ds2 = read_fits(path2)
|
|
36
|
+
return np.allclose(ds1.time_s, ds2.time_s, atol=time_atol)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def combine_frequency(path1: str | Path, path2: str | Path) -> DynamicSpectrum:
|
|
40
|
+
"""
|
|
41
|
+
Stack two spectra along frequency axis (vertical stacking).
|
|
42
|
+
"""
|
|
43
|
+
ds1 = read_fits(path1)
|
|
44
|
+
ds2 = read_fits(path2)
|
|
45
|
+
|
|
46
|
+
data = np.vstack([ds1.data, ds2.data])
|
|
47
|
+
freqs = np.concatenate([ds1.freqs_mhz, ds2.freqs_mhz])
|
|
48
|
+
|
|
49
|
+
meta = dict(ds1.meta)
|
|
50
|
+
meta["combined"] = {"mode": "frequency", "sources": [str(ds1.source), str(ds2.source)]}
|
|
51
|
+
return DynamicSpectrum(data=data, freqs_mhz=freqs, time_s=ds1.time_s, source=None, meta=meta)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def can_combine_time(paths: Iterable[str | Path], freq_atol: float = 0.01) -> bool:
|
|
55
|
+
"""
|
|
56
|
+
True if all files:
|
|
57
|
+
- same station/date/focus
|
|
58
|
+
- same frequency axis within tolerance
|
|
59
|
+
"""
|
|
60
|
+
paths = list(paths)
|
|
61
|
+
if len(paths) < 2:
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
parts = [parse_callisto_filename(p) for p in paths]
|
|
65
|
+
stations = {p.station for p in parts}
|
|
66
|
+
dates = {p.date_yyyymmdd for p in parts}
|
|
67
|
+
focuses = {p.focus for p in parts}
|
|
68
|
+
|
|
69
|
+
if len(stations) != 1 or len(dates) != 1 or len(focuses) != 1:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
ref = read_fits(paths[0]).freqs_mhz
|
|
73
|
+
for p in paths[1:]:
|
|
74
|
+
freqs = read_fits(p).freqs_mhz
|
|
75
|
+
if not np.allclose(freqs, ref, atol=freq_atol):
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def combine_time(paths: Iterable[str | Path]) -> DynamicSpectrum:
|
|
82
|
+
"""
|
|
83
|
+
Concatenate spectra along time axis (horizontal concatenation).
|
|
84
|
+
Assumes all have identical frequency axis.
|
|
85
|
+
"""
|
|
86
|
+
paths = sorted(list(paths), key=lambda p: parse_callisto_filename(p).time_hhmmss)
|
|
87
|
+
|
|
88
|
+
ds0 = read_fits(paths[0])
|
|
89
|
+
combined_data = ds0.data
|
|
90
|
+
combined_time = ds0.time_s
|
|
91
|
+
freqs = ds0.freqs_mhz
|
|
92
|
+
|
|
93
|
+
for p in paths[1:]:
|
|
94
|
+
ds = read_fits(p)
|
|
95
|
+
|
|
96
|
+
if ds.time_s.size > 1:
|
|
97
|
+
dt = float(ds.time_s[1] - ds.time_s[0])
|
|
98
|
+
else:
|
|
99
|
+
dt = 1.0
|
|
100
|
+
|
|
101
|
+
shift = float(combined_time[-1] + dt)
|
|
102
|
+
adjusted_time = ds.time_s + shift
|
|
103
|
+
|
|
104
|
+
combined_data = np.concatenate([combined_data, ds.data], axis=1)
|
|
105
|
+
combined_time = np.concatenate([combined_time, adjusted_time])
|
|
106
|
+
|
|
107
|
+
meta = dict(ds0.meta)
|
|
108
|
+
meta["combined"] = {"mode": "time", "sources": [str(Path(p)) for p in paths]}
|
|
109
|
+
return DynamicSpectrum(data=combined_data, freqs_mhz=freqs, time_s=combined_time, source=None, meta=meta)
|
|
110
|
+
|
ecallistolib/crop.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
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 typing import Optional, Tuple, Union
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from .exceptions import CropError
|
|
15
|
+
from .models import DynamicSpectrum
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def crop_frequency(
|
|
19
|
+
ds: DynamicSpectrum,
|
|
20
|
+
freq_min: Optional[float] = None,
|
|
21
|
+
freq_max: Optional[float] = None,
|
|
22
|
+
) -> DynamicSpectrum:
|
|
23
|
+
"""
|
|
24
|
+
Crop a DynamicSpectrum to a frequency range.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
ds : DynamicSpectrum
|
|
29
|
+
Input spectrum to crop.
|
|
30
|
+
freq_min : float, optional
|
|
31
|
+
Minimum frequency in MHz (inclusive). If None, uses the minimum frequency in the data.
|
|
32
|
+
freq_max : float, optional
|
|
33
|
+
Maximum frequency in MHz (inclusive). If None, uses the maximum frequency in the data.
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
DynamicSpectrum
|
|
38
|
+
Cropped spectrum containing only frequencies in [freq_min, freq_max].
|
|
39
|
+
|
|
40
|
+
Raises
|
|
41
|
+
------
|
|
42
|
+
CropError
|
|
43
|
+
If the frequency range is invalid or results in empty data.
|
|
44
|
+
"""
|
|
45
|
+
freqs = ds.freqs_mhz
|
|
46
|
+
actual_min, actual_max = float(freqs.min()), float(freqs.max())
|
|
47
|
+
|
|
48
|
+
if freq_min is None:
|
|
49
|
+
freq_min = actual_min
|
|
50
|
+
if freq_max is None:
|
|
51
|
+
freq_max = actual_max
|
|
52
|
+
|
|
53
|
+
if freq_min > freq_max:
|
|
54
|
+
raise CropError(f"freq_min ({freq_min}) must be <= freq_max ({freq_max})")
|
|
55
|
+
|
|
56
|
+
# Find indices within range
|
|
57
|
+
mask = (freqs >= freq_min) & (freqs <= freq_max)
|
|
58
|
+
|
|
59
|
+
if not mask.any():
|
|
60
|
+
raise CropError(
|
|
61
|
+
f"No frequencies in range [{freq_min}, {freq_max}] MHz. "
|
|
62
|
+
f"Data range is [{actual_min}, {actual_max}] MHz."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
indices = np.where(mask)[0]
|
|
66
|
+
cropped_data = ds.data[indices, :]
|
|
67
|
+
cropped_freqs = freqs[indices]
|
|
68
|
+
|
|
69
|
+
meta = dict(ds.meta)
|
|
70
|
+
meta["cropped"] = meta.get("cropped", {})
|
|
71
|
+
meta["cropped"]["frequency"] = {"min": freq_min, "max": freq_max}
|
|
72
|
+
|
|
73
|
+
return ds.copy_with(data=cropped_data, freqs_mhz=cropped_freqs, meta=meta)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def crop_time(
|
|
77
|
+
ds: DynamicSpectrum,
|
|
78
|
+
time_min: Optional[float] = None,
|
|
79
|
+
time_max: Optional[float] = None,
|
|
80
|
+
) -> DynamicSpectrum:
|
|
81
|
+
"""
|
|
82
|
+
Crop a DynamicSpectrum to a time range.
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
ds : DynamicSpectrum
|
|
87
|
+
Input spectrum to crop.
|
|
88
|
+
time_min : float, optional
|
|
89
|
+
Minimum time in seconds (inclusive). If None, uses the minimum time in the data.
|
|
90
|
+
time_max : float, optional
|
|
91
|
+
Maximum time in seconds (inclusive). If None, uses the maximum time in the data.
|
|
92
|
+
|
|
93
|
+
Returns
|
|
94
|
+
-------
|
|
95
|
+
DynamicSpectrum
|
|
96
|
+
Cropped spectrum containing only times in [time_min, time_max].
|
|
97
|
+
|
|
98
|
+
Raises
|
|
99
|
+
------
|
|
100
|
+
CropError
|
|
101
|
+
If the time range is invalid or results in empty data.
|
|
102
|
+
"""
|
|
103
|
+
times = ds.time_s
|
|
104
|
+
actual_min, actual_max = float(times.min()), float(times.max())
|
|
105
|
+
|
|
106
|
+
if time_min is None:
|
|
107
|
+
time_min = actual_min
|
|
108
|
+
if time_max is None:
|
|
109
|
+
time_max = actual_max
|
|
110
|
+
|
|
111
|
+
if time_min > time_max:
|
|
112
|
+
raise CropError(f"time_min ({time_min}) must be <= time_max ({time_max})")
|
|
113
|
+
|
|
114
|
+
# Find indices within range
|
|
115
|
+
mask = (times >= time_min) & (times <= time_max)
|
|
116
|
+
|
|
117
|
+
if not mask.any():
|
|
118
|
+
raise CropError(
|
|
119
|
+
f"No times in range [{time_min}, {time_max}] s. "
|
|
120
|
+
f"Data range is [{actual_min}, {actual_max}] s."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
indices = np.where(mask)[0]
|
|
124
|
+
cropped_data = ds.data[:, indices]
|
|
125
|
+
cropped_times = times[indices]
|
|
126
|
+
|
|
127
|
+
meta = dict(ds.meta)
|
|
128
|
+
meta["cropped"] = meta.get("cropped", {})
|
|
129
|
+
meta["cropped"]["time"] = {"min": time_min, "max": time_max}
|
|
130
|
+
|
|
131
|
+
return ds.copy_with(data=cropped_data, time_s=cropped_times, meta=meta)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def crop(
|
|
135
|
+
ds: DynamicSpectrum,
|
|
136
|
+
freq_range: Optional[Tuple[Optional[float], Optional[float]]] = None,
|
|
137
|
+
time_range: Optional[Tuple[Optional[float], Optional[float]]] = None,
|
|
138
|
+
) -> DynamicSpectrum:
|
|
139
|
+
"""
|
|
140
|
+
Crop a DynamicSpectrum to specified frequency and/or time ranges.
|
|
141
|
+
|
|
142
|
+
Parameters
|
|
143
|
+
----------
|
|
144
|
+
ds : DynamicSpectrum
|
|
145
|
+
Input spectrum to crop.
|
|
146
|
+
freq_range : tuple of (min, max), optional
|
|
147
|
+
Frequency range in MHz. Use None for either bound to keep original.
|
|
148
|
+
time_range : tuple of (min, max), optional
|
|
149
|
+
Time range in seconds. Use None for either bound to keep original.
|
|
150
|
+
|
|
151
|
+
Returns
|
|
152
|
+
-------
|
|
153
|
+
DynamicSpectrum
|
|
154
|
+
Cropped spectrum.
|
|
155
|
+
|
|
156
|
+
Examples
|
|
157
|
+
--------
|
|
158
|
+
>>> # Crop to 100-200 MHz
|
|
159
|
+
>>> cropped = crop(ds, freq_range=(100, 200))
|
|
160
|
+
|
|
161
|
+
>>> # Crop to first 60 seconds
|
|
162
|
+
>>> cropped = crop(ds, time_range=(0, 60))
|
|
163
|
+
|
|
164
|
+
>>> # Crop both axes
|
|
165
|
+
>>> cropped = crop(ds, freq_range=(100, 200), time_range=(0, 60))
|
|
166
|
+
"""
|
|
167
|
+
result = ds
|
|
168
|
+
|
|
169
|
+
if freq_range is not None:
|
|
170
|
+
freq_min, freq_max = freq_range
|
|
171
|
+
result = crop_frequency(result, freq_min, freq_max)
|
|
172
|
+
|
|
173
|
+
if time_range is not None:
|
|
174
|
+
time_min, time_max = time_range
|
|
175
|
+
result = crop_time(result, time_min, time_max)
|
|
176
|
+
|
|
177
|
+
return result
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def slice_by_index(
|
|
181
|
+
ds: DynamicSpectrum,
|
|
182
|
+
freq_slice: Optional[slice] = None,
|
|
183
|
+
time_slice: Optional[slice] = None,
|
|
184
|
+
) -> DynamicSpectrum:
|
|
185
|
+
"""
|
|
186
|
+
Slice a DynamicSpectrum by array indices.
|
|
187
|
+
|
|
188
|
+
Parameters
|
|
189
|
+
----------
|
|
190
|
+
ds : DynamicSpectrum
|
|
191
|
+
Input spectrum to slice.
|
|
192
|
+
freq_slice : slice, optional
|
|
193
|
+
Slice object for frequency axis. E.g., slice(0, 100) for first 100 channels.
|
|
194
|
+
time_slice : slice, optional
|
|
195
|
+
Slice object for time axis. E.g., slice(0, 500) for first 500 samples.
|
|
196
|
+
|
|
197
|
+
Returns
|
|
198
|
+
-------
|
|
199
|
+
DynamicSpectrum
|
|
200
|
+
Sliced spectrum.
|
|
201
|
+
|
|
202
|
+
Examples
|
|
203
|
+
--------
|
|
204
|
+
>>> # Get first 100 frequency channels
|
|
205
|
+
>>> sliced = slice_by_index(ds, freq_slice=slice(0, 100))
|
|
206
|
+
|
|
207
|
+
>>> # Get every other time sample
|
|
208
|
+
>>> sliced = slice_by_index(ds, time_slice=slice(None, None, 2))
|
|
209
|
+
"""
|
|
210
|
+
if freq_slice is None:
|
|
211
|
+
freq_slice = slice(None)
|
|
212
|
+
if time_slice is None:
|
|
213
|
+
time_slice = slice(None)
|
|
214
|
+
|
|
215
|
+
sliced_data = ds.data[freq_slice, time_slice]
|
|
216
|
+
sliced_freqs = ds.freqs_mhz[freq_slice]
|
|
217
|
+
sliced_times = ds.time_s[time_slice]
|
|
218
|
+
|
|
219
|
+
if sliced_data.size == 0:
|
|
220
|
+
raise CropError("Slice resulted in empty data array")
|
|
221
|
+
|
|
222
|
+
meta = dict(ds.meta)
|
|
223
|
+
meta["sliced"] = {"freq_slice": str(freq_slice), "time_slice": str(time_slice)}
|
|
224
|
+
|
|
225
|
+
return ds.copy_with(data=sliced_data, freqs_mhz=sliced_freqs, time_s=sliced_times, meta=meta)
|
ecallistolib/download.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
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 date
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Iterable, List, Optional
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
from bs4 import BeautifulSoup
|
|
17
|
+
|
|
18
|
+
from .exceptions import DownloadError
|
|
19
|
+
|
|
20
|
+
DEFAULT_BASE_URL = "http://soleil80.cs.technik.fhnw.ch/solarradio/data/2002-20yy_Callisto/"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class RemoteFITS:
|
|
25
|
+
"""Represents a remote FITS file available for download."""
|
|
26
|
+
name: str
|
|
27
|
+
url: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def list_remote_fits(
|
|
31
|
+
day: date,
|
|
32
|
+
hour: int,
|
|
33
|
+
station_substring: str,
|
|
34
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
35
|
+
timeout_s: float = 10.0,
|
|
36
|
+
) -> List[RemoteFITS]:
|
|
37
|
+
"""
|
|
38
|
+
Return RemoteFITS entries for a given day/hour and station substring.
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
day : date
|
|
43
|
+
The date to search for files.
|
|
44
|
+
hour : int
|
|
45
|
+
UTC hour (0-23) to filter files.
|
|
46
|
+
station_substring : str
|
|
47
|
+
Case-insensitive substring to match station names.
|
|
48
|
+
base_url : str
|
|
49
|
+
Base URL of the e-CALLISTO archive.
|
|
50
|
+
timeout_s : float
|
|
51
|
+
HTTP request timeout in seconds.
|
|
52
|
+
|
|
53
|
+
Returns
|
|
54
|
+
-------
|
|
55
|
+
List[RemoteFITS]
|
|
56
|
+
List of available remote FITS files matching the criteria.
|
|
57
|
+
|
|
58
|
+
Raises
|
|
59
|
+
------
|
|
60
|
+
ValueError
|
|
61
|
+
If hour is not in [0, 23].
|
|
62
|
+
DownloadError
|
|
63
|
+
If the remote server cannot be reached or returns an error.
|
|
64
|
+
"""
|
|
65
|
+
if not (0 <= hour <= 23):
|
|
66
|
+
raise ValueError("hour must be in [0, 23]")
|
|
67
|
+
|
|
68
|
+
url_day = f"{base_url.rstrip('/')}/{day.year}/{day.month:02}/{day.day:02}/"
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
r = requests.get(url_day, timeout=timeout_s)
|
|
72
|
+
r.raise_for_status()
|
|
73
|
+
except requests.exceptions.Timeout:
|
|
74
|
+
raise DownloadError(f"Timeout while connecting to {url_day}")
|
|
75
|
+
except requests.exceptions.ConnectionError as e:
|
|
76
|
+
raise DownloadError(f"Failed to connect to {url_day}: {e}")
|
|
77
|
+
except requests.exceptions.HTTPError as e:
|
|
78
|
+
raise DownloadError(f"HTTP error accessing {url_day}: {e}")
|
|
79
|
+
|
|
80
|
+
soup = BeautifulSoup(r.content, "html.parser")
|
|
81
|
+
fits_files = [a.get("href") for a in soup.find_all("a") if a.get("href", "").endswith(".fit.gz")]
|
|
82
|
+
|
|
83
|
+
out: List[RemoteFITS] = []
|
|
84
|
+
station_substring = station_substring.lower().strip()
|
|
85
|
+
|
|
86
|
+
for fn in fits_files:
|
|
87
|
+
if station_substring and (station_substring not in fn.lower()):
|
|
88
|
+
continue
|
|
89
|
+
parts = fn.split("_")
|
|
90
|
+
if len(parts) >= 3:
|
|
91
|
+
try:
|
|
92
|
+
hh = int(parts[2][:2])
|
|
93
|
+
if hh == hour:
|
|
94
|
+
out.append(RemoteFITS(name=fn, url=url_day + fn))
|
|
95
|
+
except ValueError:
|
|
96
|
+
# Skip files with invalid time format
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def download_files(
|
|
103
|
+
items: Iterable[RemoteFITS],
|
|
104
|
+
out_dir: str | Path,
|
|
105
|
+
timeout_s: float = 30.0,
|
|
106
|
+
) -> List[Path]:
|
|
107
|
+
"""
|
|
108
|
+
Download FITS files to a local directory.
|
|
109
|
+
|
|
110
|
+
Parameters
|
|
111
|
+
----------
|
|
112
|
+
items : Iterable[RemoteFITS]
|
|
113
|
+
Remote FITS files to download.
|
|
114
|
+
out_dir : str or Path
|
|
115
|
+
Output directory for downloaded files.
|
|
116
|
+
timeout_s : float
|
|
117
|
+
HTTP request timeout per file in seconds.
|
|
118
|
+
|
|
119
|
+
Returns
|
|
120
|
+
-------
|
|
121
|
+
List[Path]
|
|
122
|
+
List of paths to saved files.
|
|
123
|
+
|
|
124
|
+
Raises
|
|
125
|
+
------
|
|
126
|
+
DownloadError
|
|
127
|
+
If a file cannot be downloaded.
|
|
128
|
+
"""
|
|
129
|
+
out_dir = Path(out_dir)
|
|
130
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
|
|
132
|
+
saved: List[Path] = []
|
|
133
|
+
with requests.Session() as s:
|
|
134
|
+
for it in items:
|
|
135
|
+
try:
|
|
136
|
+
r = s.get(it.url, timeout=timeout_s)
|
|
137
|
+
r.raise_for_status()
|
|
138
|
+
except requests.exceptions.Timeout:
|
|
139
|
+
raise DownloadError(f"Timeout downloading {it.name}")
|
|
140
|
+
except requests.exceptions.RequestException as e:
|
|
141
|
+
raise DownloadError(f"Failed to download {it.name}: {e}")
|
|
142
|
+
|
|
143
|
+
target = out_dir / it.name
|
|
144
|
+
target.write_bytes(r.content)
|
|
145
|
+
saved.append(target)
|
|
146
|
+
|
|
147
|
+
return saved
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
|
|
11
|
+
class ECallistoError(Exception):
|
|
12
|
+
"""Base exception for all ecallistolib errors."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InvalidFITSError(ECallistoError):
|
|
17
|
+
"""Raised when a FITS file is invalid or cannot be read."""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InvalidFilenameError(ECallistoError):
|
|
22
|
+
"""Raised when a filename doesn't match e-CALLISTO naming convention."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DownloadError(ECallistoError):
|
|
27
|
+
"""Raised when downloading files from the archive fails."""
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CombineError(ECallistoError):
|
|
32
|
+
"""Raised when spectra cannot be combined."""
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CropError(ECallistoError):
|
|
37
|
+
"""Raised when cropping parameters are invalid."""
|
|
38
|
+
pass
|