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.
@@ -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}")
@@ -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)
@@ -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