isgri 0.3.0__py3-none-any.whl → 0.5.0__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.
- isgri/__init__.py +1 -0
- isgri/__version__.py +1 -0
- isgri/catalog/__init__.py +3 -0
- isgri/catalog/builder.py +90 -0
- isgri/catalog/scwquery.py +524 -0
- isgri/catalog/wcs.py +190 -0
- isgri/cli.py +224 -0
- isgri/config.py +151 -0
- isgri/utils/file_loaders.py +392 -159
- isgri/utils/lightcurve.py +409 -265
- isgri/utils/pif.py +286 -41
- isgri/utils/quality.py +389 -182
- isgri/utils/time_conversion.py +210 -39
- isgri-0.5.0.dist-info/METADATA +164 -0
- isgri-0.5.0.dist-info/RECORD +19 -0
- isgri-0.5.0.dist-info/entry_points.txt +2 -0
- isgri-0.3.0.dist-info/METADATA +0 -66
- isgri-0.3.0.dist-info/RECORD +0 -11
- {isgri-0.3.0.dist-info → isgri-0.5.0.dist-info}/WHEEL +0 -0
- {isgri-0.3.0.dist-info → isgri-0.5.0.dist-info}/licenses/LICENSE +0 -0
isgri/__init__.py
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .__version__ import __version__
|
isgri/__version__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.5.0"
|
isgri/catalog/builder.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from isgri.utils import LightCurve, QualityMetrics
|
|
2
|
+
import numpy as np
|
|
3
|
+
import os, subprocess
|
|
4
|
+
from typing import Optional
|
|
5
|
+
from joblib import Parallel, delayed # type: ignore
|
|
6
|
+
import multiprocessing
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CatalogBuilder:
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
archive_path: str,
|
|
13
|
+
catalog_path: str,
|
|
14
|
+
lightcurve_cache: Optional[str] = None,
|
|
15
|
+
n_cores: Optional[int] = None,
|
|
16
|
+
):
|
|
17
|
+
self.archive_path = archive_path
|
|
18
|
+
self.catalog_path = catalog_path
|
|
19
|
+
self.lightcurve_cache = lightcurve_cache
|
|
20
|
+
self.n_cores = n_cores if n_cores is not None else multiprocessing.cpu_count()
|
|
21
|
+
self.catalog = self._load_catalog()
|
|
22
|
+
|
|
23
|
+
def _load_catalog(self):
|
|
24
|
+
if not os.path.exists(self.catalog_path):
|
|
25
|
+
empty_structure = CatalogStructure.get_empty_structure()
|
|
26
|
+
return empty_structure
|
|
27
|
+
else:
|
|
28
|
+
catalog = CatalogStructure.load_from_fits(self.catalog_path)
|
|
29
|
+
return catalog
|
|
30
|
+
|
|
31
|
+
def _process_scw(self, path) -> tuple[dict, list]:
|
|
32
|
+
lc = LightCurve.load_data(path)
|
|
33
|
+
|
|
34
|
+
time, full_counts = lc.rebin(1, emin=15, emax=1000, local_time=False)
|
|
35
|
+
_, module_counts = lc.rebin_by_modules(1, emin=15, emax=1000, local_time=False)
|
|
36
|
+
module_counts.insert(0, full_counts)
|
|
37
|
+
module_counts = np.array(module_counts)
|
|
38
|
+
quality = QualityMetrics.compute(lc)
|
|
39
|
+
quality.module_data = {"time": time, "counts": module_counts[1:]}
|
|
40
|
+
raw_chisq = quality.raw_chi_squared()
|
|
41
|
+
clipped_chisq = quality.sigma_clip_chi_squared()
|
|
42
|
+
gti_chisq = quality.gti_chi_squared()
|
|
43
|
+
|
|
44
|
+
# cnames = [
|
|
45
|
+
# ("REVOL", int),
|
|
46
|
+
# ("SWID", "S12"),
|
|
47
|
+
# ("TSTART", float),
|
|
48
|
+
# ("TSTOP", float),
|
|
49
|
+
# ("TELAPSE", float),
|
|
50
|
+
# ("RA_SCX", float),
|
|
51
|
+
# ("DEC_SCX", float),
|
|
52
|
+
# ("RA_SCZ", float),
|
|
53
|
+
# ("DEC_SCZ", float),
|
|
54
|
+
# ("NoEVTS", int),
|
|
55
|
+
# ("LCs", np.ndarray),
|
|
56
|
+
# ("GTIs", np.ndarray),
|
|
57
|
+
# ("CHI", float),
|
|
58
|
+
# ("CUT_CHI", float),
|
|
59
|
+
# ("GTI_CHI", float),
|
|
60
|
+
# ]
|
|
61
|
+
table_data = {
|
|
62
|
+
"REVOL": lc.metadata["REVOL"],
|
|
63
|
+
"SWID": lc.metadata["SWID"],
|
|
64
|
+
"TSTART": lc.metadata["TSTART"],
|
|
65
|
+
"TSTOP": lc.metadata["TSTOP"],
|
|
66
|
+
"ONTIME": lc.metadata["TELAPSE"],
|
|
67
|
+
"RA_SCX": lc.metadata["RA_SCX"],
|
|
68
|
+
"DEC_SCX": lc.metadata["DEC_SCX"],
|
|
69
|
+
"RA_SCZ": lc.metadata["RA_SCZ"],
|
|
70
|
+
"DEC_SCZ": lc.metadata["DEC_SCZ"],
|
|
71
|
+
"NoEVTS": len(lc.time),
|
|
72
|
+
"CHI": raw_chisq,
|
|
73
|
+
"CUT_CHI": clipped_chisq,
|
|
74
|
+
"GTI_CHI": gti_chisq,
|
|
75
|
+
}
|
|
76
|
+
array_data = [lc.metadata["SWID"], time, module_counts, lc.gti]
|
|
77
|
+
return table_data, array_data
|
|
78
|
+
|
|
79
|
+
def _process_rev(self, rev_paths: list[str]) -> tuple[list[dict], list[list]]:
|
|
80
|
+
data = Parallel(n_jobs=self.n_cores, backend="multiprocessing")(
|
|
81
|
+
delayed(self._process_scw)(path) for path in rev_paths
|
|
82
|
+
)
|
|
83
|
+
table_data_list, array_data_list = zip(*data)
|
|
84
|
+
return table_data_list, array_data_list
|
|
85
|
+
|
|
86
|
+
def _find_scws(self) -> tuple[np.ndarray[str], np.ndarray[str]]:
|
|
87
|
+
# Find all SCW files in the archive
|
|
88
|
+
scws_files = subprocess.run(
|
|
89
|
+
["find", "-L", self.archive_path, "-name", "isgri_events.fits.gz"], capture_output=True, text=True
|
|
90
|
+
)
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
from astropy.table import Table
|
|
2
|
+
from astropy.coordinates import SkyCoord
|
|
3
|
+
from astropy.time import Time
|
|
4
|
+
from astropy import units as u
|
|
5
|
+
import numpy as np
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional, Union, Literal
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from isgri.utils import ijd2utc, utc2ijd
|
|
10
|
+
from .wcs import compute_detector_offset
|
|
11
|
+
from ..config import Config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Filter:
|
|
16
|
+
"""Filter with mask and parameters"""
|
|
17
|
+
|
|
18
|
+
name: str
|
|
19
|
+
mask: np.ndarray
|
|
20
|
+
params: dict
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ScwQuery:
|
|
24
|
+
"""
|
|
25
|
+
Query interface for INTEGRAL SCW catalog.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
catalog_path : str or Path
|
|
30
|
+
Path to SCW catalog FITS file
|
|
31
|
+
|
|
32
|
+
Examples
|
|
33
|
+
--------
|
|
34
|
+
>>> query = ScwQuery("data/scw_catalog.fits")
|
|
35
|
+
>>> results = query.time(tstart=3000).quality(max_chi=2.0).get()
|
|
36
|
+
>>>
|
|
37
|
+
>>> # FOV-based filtering
|
|
38
|
+
>>> results = query.position(ra=83.63, dec=22.01, fov_mode="full").get()
|
|
39
|
+
|
|
40
|
+
See Also
|
|
41
|
+
--------
|
|
42
|
+
time : Filter by time range
|
|
43
|
+
quality : Filter by data quality
|
|
44
|
+
position : Filter by sky position
|
|
45
|
+
revolution : Filter by revolution number
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
ISGRI_FULLY_CODED = 4.0 # half-width in degrees (8x8 total)
|
|
49
|
+
ISGRI_DETECTOR_EDGE = 14.5 # half-width in degrees (29x29 total)
|
|
50
|
+
|
|
51
|
+
def __init__(self, catalog_path: Optional[Union[str, Path]] = None):
|
|
52
|
+
if catalog_path is None:
|
|
53
|
+
cfg = Config()
|
|
54
|
+
catalog_path = cfg.catalog_path
|
|
55
|
+
if catalog_path is None:
|
|
56
|
+
raise ValueError("No catalog_path provided and no catalog_path in config")
|
|
57
|
+
|
|
58
|
+
self.catalog_path = Path(catalog_path)
|
|
59
|
+
self._catalog: Optional[Table] = None
|
|
60
|
+
self._mask: Optional[np.ndarray] = None
|
|
61
|
+
self._filters: list[Filter] = []
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def catalog(self) -> Table:
|
|
65
|
+
"""Lazy load catalog from FITS file"""
|
|
66
|
+
if self._catalog is None:
|
|
67
|
+
if not self.catalog_path.exists():
|
|
68
|
+
raise FileNotFoundError(f"Catalog not found: {self.catalog_path}")
|
|
69
|
+
self._catalog = Table.read(self.catalog_path)
|
|
70
|
+
self._validate_catalog()
|
|
71
|
+
return self._catalog
|
|
72
|
+
|
|
73
|
+
def _validate_catalog(self):
|
|
74
|
+
"""Check required columns exist"""
|
|
75
|
+
required = ["SWID", "TSTART", "TSTOP", "RA_SCX", "DEC_SCX", "RA_SCZ", "DEC_SCZ", "CHI"]
|
|
76
|
+
missing = [col for col in required if col not in self._catalog.colnames]
|
|
77
|
+
if missing:
|
|
78
|
+
raise ValueError(f"Catalog missing required columns: {missing}")
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def mask(self) -> np.ndarray:
|
|
82
|
+
"""Initialize mask if needed"""
|
|
83
|
+
if self._mask is None:
|
|
84
|
+
self._mask = np.ones(len(self.catalog), dtype=bool)
|
|
85
|
+
return self._mask
|
|
86
|
+
|
|
87
|
+
def time(
|
|
88
|
+
self, tstart: Optional[Union[float, str]] = None, tstop: Optional[Union[float, str]] = None
|
|
89
|
+
) -> "ScwQuery":
|
|
90
|
+
"""
|
|
91
|
+
Filter by time range.
|
|
92
|
+
|
|
93
|
+
Parameters
|
|
94
|
+
----------
|
|
95
|
+
tstart : float or str, optional
|
|
96
|
+
Start time in IJD (float) or ISO format (str)
|
|
97
|
+
tstop : float or str, optional
|
|
98
|
+
Stop time in IJD (float) or ISO format (str)
|
|
99
|
+
|
|
100
|
+
Returns
|
|
101
|
+
-------
|
|
102
|
+
ScwQuery
|
|
103
|
+
Self for method chaining
|
|
104
|
+
|
|
105
|
+
Examples
|
|
106
|
+
--------
|
|
107
|
+
>>> query.time(tstart="2010-01-01", tstop="2010-12-31")
|
|
108
|
+
>>> query.time(tstart=3000.0) # IJD format
|
|
109
|
+
>>> query.time(tstop="2015-01-01") # Only upper bound
|
|
110
|
+
"""
|
|
111
|
+
mask = np.ones(len(self.catalog), dtype=bool)
|
|
112
|
+
|
|
113
|
+
if tstart is not None:
|
|
114
|
+
tstart_ijd = self._parse_time(tstart)
|
|
115
|
+
mask &= self.catalog["TSTOP"] >= tstart_ijd
|
|
116
|
+
|
|
117
|
+
if tstop is not None:
|
|
118
|
+
tstop_ijd = self._parse_time(tstop)
|
|
119
|
+
mask &= self.catalog["TSTART"] <= tstop_ijd
|
|
120
|
+
|
|
121
|
+
self._add_filter(Filter(name="time", mask=mask, params={"tstart": tstart, "tstop": tstop}))
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
def quality(self, max_chi: Optional[float] = None, chi_type: str = "CHI") -> "ScwQuery":
|
|
125
|
+
"""
|
|
126
|
+
Filter by quality metric (lower chi-squared means better quality).
|
|
127
|
+
|
|
128
|
+
Parameters
|
|
129
|
+
----------
|
|
130
|
+
max_chi : float, optional
|
|
131
|
+
Maximum chi-squared value to accept
|
|
132
|
+
chi_type : str, default "CHI"
|
|
133
|
+
Column name: "CHI", "CUT_CHI", or "GTI_CHI"
|
|
134
|
+
|
|
135
|
+
Returns
|
|
136
|
+
-------
|
|
137
|
+
ScwQuery
|
|
138
|
+
Self for method chaining
|
|
139
|
+
|
|
140
|
+
Examples
|
|
141
|
+
--------
|
|
142
|
+
>>> query.quality(max_chi=2.0) # High quality data
|
|
143
|
+
>>> query.quality(max_chi=5.0, chi_type="CUT_CHI") # Alternative metric
|
|
144
|
+
|
|
145
|
+
"""
|
|
146
|
+
if chi_type not in self.catalog.colnames:
|
|
147
|
+
raise ValueError(f"Column {chi_type} not found in catalog")
|
|
148
|
+
|
|
149
|
+
mask = np.ones(len(self.catalog), dtype=bool)
|
|
150
|
+
|
|
151
|
+
if max_chi is not None:
|
|
152
|
+
if max_chi <= 0:
|
|
153
|
+
raise ValueError("max_chi must be positive")
|
|
154
|
+
mask &= self.catalog[chi_type] <= max_chi
|
|
155
|
+
|
|
156
|
+
self._add_filter(Filter(name="quality", mask=mask, params={"max_chi": max_chi, "chi_type": chi_type}))
|
|
157
|
+
return self
|
|
158
|
+
|
|
159
|
+
def position(
|
|
160
|
+
self,
|
|
161
|
+
ra: Optional[Union[float, str]] = None,
|
|
162
|
+
dec: Optional[Union[float, str]] = None,
|
|
163
|
+
radius: Optional[float] = None,
|
|
164
|
+
target: Optional[SkyCoord] = None,
|
|
165
|
+
fov_mode: Optional[Literal["full", "any"]] = None,
|
|
166
|
+
max_offset: Optional[float] = None,
|
|
167
|
+
) -> "ScwQuery":
|
|
168
|
+
"""
|
|
169
|
+
Filter by sky position using angular separation or FOV constraints.
|
|
170
|
+
|
|
171
|
+
Parameters
|
|
172
|
+
----------
|
|
173
|
+
ra : float or str, optional
|
|
174
|
+
Right ascension in degrees or HMS format
|
|
175
|
+
dec : float or str, optional
|
|
176
|
+
Declination in degrees or DMS format
|
|
177
|
+
radius : float, optional
|
|
178
|
+
Angular separation radius in degrees (simple cone search)
|
|
179
|
+
target : SkyCoord, optional
|
|
180
|
+
Target position as SkyCoord (alternative to ra/dec)
|
|
181
|
+
fov_mode : {'full', 'any'}, optional
|
|
182
|
+
FOV filtering mode using detector coordinates:
|
|
183
|
+
- 'full': fully coded FOV (both |Y| and |Z| <= 4 deg)
|
|
184
|
+
- 'any': detector FOV (both |Y| and |Z| <= 14.5 deg)
|
|
185
|
+
max_offset : float, optional
|
|
186
|
+
Custom maximum offset in degrees (uses max of |Y|, |Z|)
|
|
187
|
+
|
|
188
|
+
Returns
|
|
189
|
+
-------
|
|
190
|
+
ScwQuery
|
|
191
|
+
Self for method chaining
|
|
192
|
+
|
|
193
|
+
Notes
|
|
194
|
+
-----
|
|
195
|
+
When fov_mode or max_offset is specified, uses compute_detector_offset() to calculate
|
|
196
|
+
detector Y/Z offsets from pointing center. Otherwise uses simple angular
|
|
197
|
+
separation from X-axis pointing.
|
|
198
|
+
|
|
199
|
+
Examples
|
|
200
|
+
--------
|
|
201
|
+
>>> query.position(ra=83.63, dec=22.01, radius=5.0)
|
|
202
|
+
>>> query.position(ra=83.63, dec=22.01, fov_mode="full")
|
|
203
|
+
>>> query.position(ra="05h34m31s", dec="+22d00m52s", fov_mode="any")
|
|
204
|
+
"""
|
|
205
|
+
if target is None:
|
|
206
|
+
if ra is None or dec is None:
|
|
207
|
+
return self
|
|
208
|
+
target = self._parse_position(ra, dec)
|
|
209
|
+
|
|
210
|
+
if isinstance(ra, (int, float)) and not (0 <= ra < 360):
|
|
211
|
+
raise ValueError(f"RA must be in [0, 360), got {ra}")
|
|
212
|
+
|
|
213
|
+
if isinstance(dec, (int, float)) and not (-90 <= dec <= 90):
|
|
214
|
+
raise ValueError(f"Dec must be in [-90, 90], got {dec}")
|
|
215
|
+
|
|
216
|
+
if radius is not None and radius <= 0:
|
|
217
|
+
raise ValueError("radius must be positive")
|
|
218
|
+
|
|
219
|
+
if fov_mode is not None and fov_mode not in ["full", "any"]:
|
|
220
|
+
raise ValueError(f"Invalid fov_mode: {fov_mode}. Use 'full' or 'any'")
|
|
221
|
+
|
|
222
|
+
mask = np.ones(len(self.catalog), dtype=bool)
|
|
223
|
+
|
|
224
|
+
if fov_mode is not None or max_offset is not None:
|
|
225
|
+
y_off, z_off, max_off = self._compute_detector_offsets(target)
|
|
226
|
+
|
|
227
|
+
if fov_mode == "full":
|
|
228
|
+
mask &= (np.abs(y_off) <= self.ISGRI_FULLY_CODED) & (np.abs(z_off) <= self.ISGRI_FULLY_CODED)
|
|
229
|
+
filter_params = {
|
|
230
|
+
"ra": target.ra.deg,
|
|
231
|
+
"dec": target.dec.deg,
|
|
232
|
+
"fov_mode": "full",
|
|
233
|
+
"max_offset": self.ISGRI_FULLY_CODED,
|
|
234
|
+
"y_offset": y_off,
|
|
235
|
+
"z_offset": z_off,
|
|
236
|
+
"max_offset_actual": max_off,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
elif fov_mode == "any":
|
|
240
|
+
mask &= (np.abs(y_off) <= self.ISGRI_DETECTOR_EDGE) & (np.abs(z_off) <= self.ISGRI_DETECTOR_EDGE)
|
|
241
|
+
filter_params = {
|
|
242
|
+
"ra": target.ra.deg,
|
|
243
|
+
"dec": target.dec.deg,
|
|
244
|
+
"fov_mode": "any",
|
|
245
|
+
"max_offset": self.ISGRI_DETECTOR_EDGE,
|
|
246
|
+
"y_offset": y_off,
|
|
247
|
+
"z_offset": z_off,
|
|
248
|
+
"max_offset_actual": max_off,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
elif max_offset is not None:
|
|
252
|
+
mask &= max_off <= max_offset
|
|
253
|
+
filter_params = {
|
|
254
|
+
"ra": target.ra.deg,
|
|
255
|
+
"dec": target.dec.deg,
|
|
256
|
+
"fov_mode": "custom",
|
|
257
|
+
"max_offset": max_offset,
|
|
258
|
+
"y_offset": y_off,
|
|
259
|
+
"z_offset": z_off,
|
|
260
|
+
"max_offset_actual": max_off,
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
else:
|
|
264
|
+
pointings_x = SkyCoord(self.catalog["RA_SCX"], self.catalog["DEC_SCX"], unit="deg")
|
|
265
|
+
separations = target.separation(pointings_x).deg
|
|
266
|
+
|
|
267
|
+
if radius is not None:
|
|
268
|
+
if radius <= 0:
|
|
269
|
+
raise ValueError("radius must be positive")
|
|
270
|
+
mask &= separations <= radius
|
|
271
|
+
|
|
272
|
+
filter_params = {
|
|
273
|
+
"ra": target.ra.deg,
|
|
274
|
+
"dec": target.dec.deg,
|
|
275
|
+
"radius": radius,
|
|
276
|
+
"separations": separations,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
self._add_filter(Filter(name="position", mask=mask, params=filter_params))
|
|
280
|
+
return self
|
|
281
|
+
|
|
282
|
+
def revolution(self, revolutions: Union[int, str, list[Union[int, str]]]) -> "ScwQuery":
|
|
283
|
+
"""
|
|
284
|
+
Filter by revolution number(s).
|
|
285
|
+
|
|
286
|
+
Parameters
|
|
287
|
+
----------
|
|
288
|
+
revolutions : int, str, or list
|
|
289
|
+
Revolution number(s) as integer (255), 4-digit string ("0255"),
|
|
290
|
+
or list of mixed types
|
|
291
|
+
|
|
292
|
+
Returns
|
|
293
|
+
-------
|
|
294
|
+
ScwQuery
|
|
295
|
+
Self for method chaining
|
|
296
|
+
|
|
297
|
+
Examples
|
|
298
|
+
--------
|
|
299
|
+
>>> query.revolution(255)
|
|
300
|
+
>>> query.revolution("0255")
|
|
301
|
+
>>> query.revolution([255, "0256", 300])
|
|
302
|
+
"""
|
|
303
|
+
if not isinstance(revolutions, list):
|
|
304
|
+
revolutions = [revolutions]
|
|
305
|
+
|
|
306
|
+
rev_ints = []
|
|
307
|
+
for rev in revolutions:
|
|
308
|
+
if isinstance(rev, int):
|
|
309
|
+
rev_ints.append(rev)
|
|
310
|
+
elif isinstance(rev, str):
|
|
311
|
+
if len(rev) != 4:
|
|
312
|
+
raise ValueError(f"Revolution string must be 4 digits: '{rev}'")
|
|
313
|
+
try:
|
|
314
|
+
rev_ints.append(int(rev))
|
|
315
|
+
except ValueError:
|
|
316
|
+
raise ValueError(f"Invalid revolution string: '{rev}'")
|
|
317
|
+
else:
|
|
318
|
+
raise TypeError(f"Revolution must be int or str, got {type(rev)}")
|
|
319
|
+
|
|
320
|
+
mask = np.isin(self.catalog["REVOL"], rev_ints)
|
|
321
|
+
self._add_filter(Filter(name="revolution", mask=mask, params={"revolutions": rev_ints}))
|
|
322
|
+
return self
|
|
323
|
+
|
|
324
|
+
def get(self) -> Table:
|
|
325
|
+
"""
|
|
326
|
+
Apply all filters and return filtered catalog.
|
|
327
|
+
|
|
328
|
+
Returns
|
|
329
|
+
-------
|
|
330
|
+
Table
|
|
331
|
+
Filtered catalog as astropy Table
|
|
332
|
+
|
|
333
|
+
Notes
|
|
334
|
+
-----
|
|
335
|
+
This is typically the final call in a filter chain:
|
|
336
|
+
|
|
337
|
+
Examples
|
|
338
|
+
--------
|
|
339
|
+
>>> results = query.time(tstart=3000).quality(max_chi=2.0).get()
|
|
340
|
+
>>> print(len(results))
|
|
341
|
+
"""
|
|
342
|
+
combined_mask = self.mask.copy()
|
|
343
|
+
for filt in self._filters:
|
|
344
|
+
combined_mask &= filt.mask
|
|
345
|
+
return self.catalog[combined_mask]
|
|
346
|
+
|
|
347
|
+
def count(self) -> int:
|
|
348
|
+
"""
|
|
349
|
+
Count SCWs matching current filters.
|
|
350
|
+
|
|
351
|
+
Returns
|
|
352
|
+
-------
|
|
353
|
+
int
|
|
354
|
+
Number of matching SCWs
|
|
355
|
+
|
|
356
|
+
Examples
|
|
357
|
+
--------
|
|
358
|
+
>>> query.time(tstart=3000).count()
|
|
359
|
+
150
|
|
360
|
+
>>> # Faster than len(query.get()) for large catalogs
|
|
361
|
+
"""
|
|
362
|
+
return len(self.get())
|
|
363
|
+
|
|
364
|
+
def reset(self) -> "ScwQuery":
|
|
365
|
+
"""
|
|
366
|
+
Clear all filters and reset to full catalog.
|
|
367
|
+
|
|
368
|
+
Returns
|
|
369
|
+
-------
|
|
370
|
+
ScwQuery
|
|
371
|
+
Self for method chaining
|
|
372
|
+
|
|
373
|
+
Examples
|
|
374
|
+
--------
|
|
375
|
+
>>> query.time(tstart=3000).get() # First query
|
|
376
|
+
>>> query.reset() # Clear filters
|
|
377
|
+
>>> query.quality(max_chi=2.0).get() # New query
|
|
378
|
+
"""
|
|
379
|
+
self._filters.clear()
|
|
380
|
+
self._mask = None
|
|
381
|
+
return self
|
|
382
|
+
|
|
383
|
+
def _compute_detector_offsets(self, target: SkyCoord) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
384
|
+
"""
|
|
385
|
+
Compute detector Y/Z offsets using compute_detector_offset.
|
|
386
|
+
|
|
387
|
+
Parameters
|
|
388
|
+
----------
|
|
389
|
+
target : SkyCoord
|
|
390
|
+
Target sky position
|
|
391
|
+
|
|
392
|
+
Returns
|
|
393
|
+
-------
|
|
394
|
+
y_offset : ndarray
|
|
395
|
+
Y-axis offsets in degrees
|
|
396
|
+
z_offset : ndarray
|
|
397
|
+
Z-axis offsets in degrees
|
|
398
|
+
max_offset : ndarray
|
|
399
|
+
Maximum of |Y| and |Z| offsets
|
|
400
|
+
"""
|
|
401
|
+
y_off, z_off = compute_detector_offset(
|
|
402
|
+
target.ra.deg,
|
|
403
|
+
target.dec.deg,
|
|
404
|
+
self.catalog["RA_SCX"],
|
|
405
|
+
self.catalog["DEC_SCX"],
|
|
406
|
+
self.catalog["RA_SCZ"],
|
|
407
|
+
self.catalog["DEC_SCZ"],
|
|
408
|
+
)
|
|
409
|
+
max_off = np.maximum(np.abs(y_off), np.abs(z_off))
|
|
410
|
+
return y_off, z_off, max_off
|
|
411
|
+
|
|
412
|
+
def _add_filter(self, filter: Filter):
|
|
413
|
+
"""Replace existing filter with same name or add new filter"""
|
|
414
|
+
self._filters = [f for f in self._filters if f.name != filter.name]
|
|
415
|
+
self._filters.append(filter)
|
|
416
|
+
|
|
417
|
+
def _parse_time(self, time: Union[float, str]) -> float:
|
|
418
|
+
"""
|
|
419
|
+
Parse time to IJD format.
|
|
420
|
+
|
|
421
|
+
Parameters
|
|
422
|
+
----------
|
|
423
|
+
time : float or str
|
|
424
|
+
Time as IJD (< 51544), MJD (>= 51544), or ISO string
|
|
425
|
+
|
|
426
|
+
Returns
|
|
427
|
+
-------
|
|
428
|
+
float
|
|
429
|
+
Time in IJD format
|
|
430
|
+
"""
|
|
431
|
+
if isinstance(time, (int, float)):
|
|
432
|
+
return time if time < 51544 else time - 51544
|
|
433
|
+
if isinstance(time, str):
|
|
434
|
+
return utc2ijd(time)
|
|
435
|
+
raise TypeError(f"Invalid time type: {type(time)}")
|
|
436
|
+
|
|
437
|
+
def _parse_position(self, ra: Union[float, str], dec: Union[float, str]) -> SkyCoord:
|
|
438
|
+
"""
|
|
439
|
+
Parse coordinates to SkyCoord.
|
|
440
|
+
|
|
441
|
+
Parameters
|
|
442
|
+
----------
|
|
443
|
+
ra : float or str
|
|
444
|
+
Right ascension as degrees or HMS string
|
|
445
|
+
dec : float or str
|
|
446
|
+
Declination as degrees or DMS string
|
|
447
|
+
|
|
448
|
+
Returns
|
|
449
|
+
-------
|
|
450
|
+
SkyCoord
|
|
451
|
+
Parsed coordinate
|
|
452
|
+
"""
|
|
453
|
+
if isinstance(ra, (int, float)) and isinstance(dec, (int, float)):
|
|
454
|
+
return SkyCoord(ra, dec, unit="deg")
|
|
455
|
+
|
|
456
|
+
if isinstance(ra, str) and isinstance(dec, str):
|
|
457
|
+
try:
|
|
458
|
+
return SkyCoord(ra, dec, unit=(u.hourangle, u.deg))
|
|
459
|
+
except:
|
|
460
|
+
try:
|
|
461
|
+
return SkyCoord(ra, dec, unit="deg")
|
|
462
|
+
except Exception as e:
|
|
463
|
+
raise ValueError(f"Could not parse position: {ra}, {dec}") from e
|
|
464
|
+
|
|
465
|
+
raise TypeError(f"Invalid position types: {type(ra)}, {type(dec)}")
|
|
466
|
+
|
|
467
|
+
@property
|
|
468
|
+
def filters_summary(self) -> dict:
|
|
469
|
+
"""
|
|
470
|
+
Get summary of applied filters.
|
|
471
|
+
|
|
472
|
+
Returns
|
|
473
|
+
-------
|
|
474
|
+
dict
|
|
475
|
+
Dictionary mapping filter names to their parameters
|
|
476
|
+
"""
|
|
477
|
+
return {f.name: f.params for f in self._filters}
|
|
478
|
+
|
|
479
|
+
def get_offsets(self, ra: Union[float, str], dec: Union[float, str]) -> Table:
|
|
480
|
+
"""
|
|
481
|
+
Get filtered catalog with detector offsets computed.
|
|
482
|
+
|
|
483
|
+
Parameters
|
|
484
|
+
----------
|
|
485
|
+
ra : float or str
|
|
486
|
+
Right ascension
|
|
487
|
+
dec : float or str
|
|
488
|
+
Declination
|
|
489
|
+
|
|
490
|
+
Returns
|
|
491
|
+
-------
|
|
492
|
+
Table
|
|
493
|
+
Filtered catalog with Y_OFFSET, Z_OFFSET, MAX_OFFSET columns added
|
|
494
|
+
|
|
495
|
+
Examples
|
|
496
|
+
--------
|
|
497
|
+
>>> results = query.time(tstart=3000).get_offsets(ra=83.63, dec=22.01)
|
|
498
|
+
>>> fully_coded = results[results['MAX_OFFSET'] <= 4.0]
|
|
499
|
+
"""
|
|
500
|
+
target = self._parse_position(ra, dec)
|
|
501
|
+
y_off, z_off, max_off = self._compute_detector_offsets(target)
|
|
502
|
+
|
|
503
|
+
result = self.get()
|
|
504
|
+
combined_mask = self._get_combined_mask()
|
|
505
|
+
result["Y_OFFSET"] = y_off[combined_mask]
|
|
506
|
+
result["Z_OFFSET"] = z_off[combined_mask]
|
|
507
|
+
result["MAX_OFFSET"] = max_off[combined_mask]
|
|
508
|
+
return result
|
|
509
|
+
|
|
510
|
+
def _get_combined_mask(self) -> np.ndarray:
|
|
511
|
+
"""Get combined mask from all active filters"""
|
|
512
|
+
combined_mask = self.mask.copy()
|
|
513
|
+
for filt in self._filters:
|
|
514
|
+
combined_mask &= filt.mask
|
|
515
|
+
return combined_mask
|
|
516
|
+
|
|
517
|
+
def __repr__(self) -> str:
|
|
518
|
+
n_total = len(self.catalog)
|
|
519
|
+
n_selected = self.count()
|
|
520
|
+
return (
|
|
521
|
+
f"ScwQuery(catalog={self.catalog_path.name}, "
|
|
522
|
+
f"total={n_total}, selected={n_selected}, "
|
|
523
|
+
f"filters={list(self.filters_summary.keys())})"
|
|
524
|
+
)
|