isgri 0.2.0__py3-none-any.whl → 0.4.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/catalog/__init__.py +3 -0
- isgri/catalog/scwquery.py +517 -0
- isgri/catalog/wcs.py +190 -0
- isgri/utils/file_loaders.py +305 -75
- isgri/utils/lightcurve.py +184 -40
- isgri/utils/pif.py +273 -28
- isgri/utils/quality.py +312 -89
- isgri/utils/time_conversion.py +195 -24
- isgri-0.4.0.dist-info/METADATA +107 -0
- isgri-0.4.0.dist-info/RECORD +14 -0
- isgri-0.2.0.dist-info/METADATA +0 -66
- isgri-0.2.0.dist-info/RECORD +0 -11
- {isgri-0.2.0.dist-info → isgri-0.4.0.dist-info}/WHEEL +0 -0
- {isgri-0.2.0.dist-info → isgri-0.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,517 @@
|
|
|
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
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Filter:
|
|
15
|
+
"""Filter with mask and parameters"""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
mask: np.ndarray
|
|
19
|
+
params: dict
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ScwQuery:
|
|
23
|
+
"""
|
|
24
|
+
Query interface for INTEGRAL SCW catalog.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
catalog_path : str or Path
|
|
29
|
+
Path to SCW catalog FITS file
|
|
30
|
+
|
|
31
|
+
Examples
|
|
32
|
+
--------
|
|
33
|
+
>>> query = ScwQuery("data/scw_catalog.fits")
|
|
34
|
+
>>> results = query.time(tstart=3000).quality(max_chi=2.0).get()
|
|
35
|
+
>>>
|
|
36
|
+
>>> # FOV-based filtering
|
|
37
|
+
>>> results = query.position(ra=83.63, dec=22.01, fov_mode="full").get()
|
|
38
|
+
|
|
39
|
+
See Also
|
|
40
|
+
--------
|
|
41
|
+
time : Filter by time range
|
|
42
|
+
quality : Filter by data quality
|
|
43
|
+
position : Filter by sky position
|
|
44
|
+
revolution : Filter by revolution number
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
ISGRI_FULLY_CODED = 4.0 # half-width in degrees (8x8 total)
|
|
48
|
+
ISGRI_DETECTOR_EDGE = 14.5 # half-width in degrees (29x29 total)
|
|
49
|
+
|
|
50
|
+
def __init__(self, catalog_path: Union[str, Path]):
|
|
51
|
+
self.catalog_path = Path(catalog_path)
|
|
52
|
+
self._catalog: Optional[Table] = None
|
|
53
|
+
self._mask: Optional[np.ndarray] = None
|
|
54
|
+
self._filters: list[Filter] = []
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def catalog(self) -> Table:
|
|
58
|
+
"""Lazy load catalog from FITS file"""
|
|
59
|
+
if self._catalog is None:
|
|
60
|
+
if not self.catalog_path.exists():
|
|
61
|
+
raise FileNotFoundError(f"Catalog not found: {self.catalog_path}")
|
|
62
|
+
self._catalog = Table.read(self.catalog_path)
|
|
63
|
+
self._validate_catalog()
|
|
64
|
+
return self._catalog
|
|
65
|
+
|
|
66
|
+
def _validate_catalog(self):
|
|
67
|
+
"""Check required columns exist"""
|
|
68
|
+
required = ["SWID", "TSTART", "TSTOP", "RA_SCX", "DEC_SCX", "RA_SCZ", "DEC_SCZ", "CHI"]
|
|
69
|
+
missing = [col for col in required if col not in self._catalog.colnames]
|
|
70
|
+
if missing:
|
|
71
|
+
raise ValueError(f"Catalog missing required columns: {missing}")
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def mask(self) -> np.ndarray:
|
|
75
|
+
"""Initialize mask if needed"""
|
|
76
|
+
if self._mask is None:
|
|
77
|
+
self._mask = np.ones(len(self.catalog), dtype=bool)
|
|
78
|
+
return self._mask
|
|
79
|
+
|
|
80
|
+
def time(
|
|
81
|
+
self, tstart: Optional[Union[float, str]] = None, tstop: Optional[Union[float, str]] = None
|
|
82
|
+
) -> "ScwQuery":
|
|
83
|
+
"""
|
|
84
|
+
Filter by time range.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
tstart : float or str, optional
|
|
89
|
+
Start time in IJD (float) or ISO format (str)
|
|
90
|
+
tstop : float or str, optional
|
|
91
|
+
Stop time in IJD (float) or ISO format (str)
|
|
92
|
+
|
|
93
|
+
Returns
|
|
94
|
+
-------
|
|
95
|
+
ScwQuery
|
|
96
|
+
Self for method chaining
|
|
97
|
+
|
|
98
|
+
Examples
|
|
99
|
+
--------
|
|
100
|
+
>>> query.time(tstart="2010-01-01", tstop="2010-12-31")
|
|
101
|
+
>>> query.time(tstart=3000.0) # IJD format
|
|
102
|
+
>>> query.time(tstop="2015-01-01") # Only upper bound
|
|
103
|
+
"""
|
|
104
|
+
mask = np.ones(len(self.catalog), dtype=bool)
|
|
105
|
+
|
|
106
|
+
if tstart is not None:
|
|
107
|
+
tstart_ijd = self._parse_time(tstart)
|
|
108
|
+
mask &= self.catalog["TSTOP"] >= tstart_ijd
|
|
109
|
+
|
|
110
|
+
if tstop is not None:
|
|
111
|
+
tstop_ijd = self._parse_time(tstop)
|
|
112
|
+
mask &= self.catalog["TSTART"] <= tstop_ijd
|
|
113
|
+
|
|
114
|
+
self._add_filter(Filter(name="time", mask=mask, params={"tstart": tstart, "tstop": tstop}))
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
def quality(self, max_chi: Optional[float] = None, chi_type: str = "CHI") -> "ScwQuery":
|
|
118
|
+
"""
|
|
119
|
+
Filter by quality metric (lower chi-squared means better quality).
|
|
120
|
+
|
|
121
|
+
Parameters
|
|
122
|
+
----------
|
|
123
|
+
max_chi : float, optional
|
|
124
|
+
Maximum chi-squared value to accept
|
|
125
|
+
chi_type : str, default "CHI"
|
|
126
|
+
Column name: "CHI", "CUT_CHI", or "GTI_CHI"
|
|
127
|
+
|
|
128
|
+
Returns
|
|
129
|
+
-------
|
|
130
|
+
ScwQuery
|
|
131
|
+
Self for method chaining
|
|
132
|
+
|
|
133
|
+
Examples
|
|
134
|
+
--------
|
|
135
|
+
>>> query.quality(max_chi=2.0) # High quality data
|
|
136
|
+
>>> query.quality(max_chi=5.0, chi_type="CUT_CHI") # Alternative metric
|
|
137
|
+
|
|
138
|
+
"""
|
|
139
|
+
if chi_type not in self.catalog.colnames:
|
|
140
|
+
raise ValueError(f"Column {chi_type} not found in catalog")
|
|
141
|
+
|
|
142
|
+
mask = np.ones(len(self.catalog), dtype=bool)
|
|
143
|
+
|
|
144
|
+
if max_chi is not None:
|
|
145
|
+
if max_chi <= 0:
|
|
146
|
+
raise ValueError("max_chi must be positive")
|
|
147
|
+
mask &= self.catalog[chi_type] <= max_chi
|
|
148
|
+
|
|
149
|
+
self._add_filter(Filter(name="quality", mask=mask, params={"max_chi": max_chi, "chi_type": chi_type}))
|
|
150
|
+
return self
|
|
151
|
+
|
|
152
|
+
def position(
|
|
153
|
+
self,
|
|
154
|
+
ra: Optional[Union[float, str]] = None,
|
|
155
|
+
dec: Optional[Union[float, str]] = None,
|
|
156
|
+
radius: Optional[float] = None,
|
|
157
|
+
target: Optional[SkyCoord] = None,
|
|
158
|
+
fov_mode: Optional[Literal["full", "any"]] = None,
|
|
159
|
+
max_offset: Optional[float] = None,
|
|
160
|
+
) -> "ScwQuery":
|
|
161
|
+
"""
|
|
162
|
+
Filter by sky position using angular separation or FOV constraints.
|
|
163
|
+
|
|
164
|
+
Parameters
|
|
165
|
+
----------
|
|
166
|
+
ra : float or str, optional
|
|
167
|
+
Right ascension in degrees or HMS format
|
|
168
|
+
dec : float or str, optional
|
|
169
|
+
Declination in degrees or DMS format
|
|
170
|
+
radius : float, optional
|
|
171
|
+
Angular separation radius in degrees (simple cone search)
|
|
172
|
+
target : SkyCoord, optional
|
|
173
|
+
Target position as SkyCoord (alternative to ra/dec)
|
|
174
|
+
fov_mode : {'full', 'any'}, optional
|
|
175
|
+
FOV filtering mode using detector coordinates:
|
|
176
|
+
- 'full': fully coded FOV (both |Y| and |Z| <= 4 deg)
|
|
177
|
+
- 'any': detector FOV (both |Y| and |Z| <= 14.5 deg)
|
|
178
|
+
max_offset : float, optional
|
|
179
|
+
Custom maximum offset in degrees (uses max of |Y|, |Z|)
|
|
180
|
+
|
|
181
|
+
Returns
|
|
182
|
+
-------
|
|
183
|
+
ScwQuery
|
|
184
|
+
Self for method chaining
|
|
185
|
+
|
|
186
|
+
Notes
|
|
187
|
+
-----
|
|
188
|
+
When fov_mode or max_offset is specified, uses compute_detector_offset() to calculate
|
|
189
|
+
detector Y/Z offsets from pointing center. Otherwise uses simple angular
|
|
190
|
+
separation from X-axis pointing.
|
|
191
|
+
|
|
192
|
+
Examples
|
|
193
|
+
--------
|
|
194
|
+
>>> query.position(ra=83.63, dec=22.01, radius=5.0)
|
|
195
|
+
>>> query.position(ra=83.63, dec=22.01, fov_mode="full")
|
|
196
|
+
>>> query.position(ra="05h34m31s", dec="+22d00m52s", fov_mode="any")
|
|
197
|
+
"""
|
|
198
|
+
if target is None:
|
|
199
|
+
if ra is None or dec is None:
|
|
200
|
+
return self
|
|
201
|
+
target = self._parse_position(ra, dec)
|
|
202
|
+
|
|
203
|
+
if isinstance(ra, (int, float)) and not (0 <= ra < 360):
|
|
204
|
+
raise ValueError(f"RA must be in [0, 360), got {ra}")
|
|
205
|
+
|
|
206
|
+
if isinstance(dec, (int, float)) and not (-90 <= dec <= 90):
|
|
207
|
+
raise ValueError(f"Dec must be in [-90, 90], got {dec}")
|
|
208
|
+
|
|
209
|
+
if radius is not None and radius <= 0:
|
|
210
|
+
raise ValueError("radius must be positive")
|
|
211
|
+
|
|
212
|
+
if fov_mode is not None and fov_mode not in ['full', 'any']:
|
|
213
|
+
raise ValueError(f"Invalid fov_mode: {fov_mode}. Use 'full' or 'any'")
|
|
214
|
+
|
|
215
|
+
mask = np.ones(len(self.catalog), dtype=bool)
|
|
216
|
+
|
|
217
|
+
if fov_mode is not None or max_offset is not None:
|
|
218
|
+
y_off, z_off, max_off = self._compute_detector_offsets(target)
|
|
219
|
+
|
|
220
|
+
if fov_mode == "full":
|
|
221
|
+
mask &= (np.abs(y_off) <= self.ISGRI_FULLY_CODED) & (np.abs(z_off) <= self.ISGRI_FULLY_CODED)
|
|
222
|
+
filter_params = {
|
|
223
|
+
"ra": target.ra.deg,
|
|
224
|
+
"dec": target.dec.deg,
|
|
225
|
+
"fov_mode": "full",
|
|
226
|
+
"max_offset": self.ISGRI_FULLY_CODED,
|
|
227
|
+
"y_offset": y_off,
|
|
228
|
+
"z_offset": z_off,
|
|
229
|
+
"max_offset_actual": max_off,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
elif fov_mode == "any":
|
|
233
|
+
mask &= (np.abs(y_off) <= self.ISGRI_DETECTOR_EDGE) & (np.abs(z_off) <= self.ISGRI_DETECTOR_EDGE)
|
|
234
|
+
filter_params = {
|
|
235
|
+
"ra": target.ra.deg,
|
|
236
|
+
"dec": target.dec.deg,
|
|
237
|
+
"fov_mode": "any",
|
|
238
|
+
"max_offset": self.ISGRI_DETECTOR_EDGE,
|
|
239
|
+
"y_offset": y_off,
|
|
240
|
+
"z_offset": z_off,
|
|
241
|
+
"max_offset_actual": max_off,
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
elif max_offset is not None:
|
|
245
|
+
mask &= max_off <= max_offset
|
|
246
|
+
filter_params = {
|
|
247
|
+
"ra": target.ra.deg,
|
|
248
|
+
"dec": target.dec.deg,
|
|
249
|
+
"fov_mode": "custom",
|
|
250
|
+
"max_offset": max_offset,
|
|
251
|
+
"y_offset": y_off,
|
|
252
|
+
"z_offset": z_off,
|
|
253
|
+
"max_offset_actual": max_off,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
else:
|
|
257
|
+
pointings_x = SkyCoord(self.catalog["RA_SCX"], self.catalog["DEC_SCX"], unit="deg")
|
|
258
|
+
separations = target.separation(pointings_x).deg
|
|
259
|
+
|
|
260
|
+
if radius is not None:
|
|
261
|
+
if radius <= 0:
|
|
262
|
+
raise ValueError("radius must be positive")
|
|
263
|
+
mask &= separations <= radius
|
|
264
|
+
|
|
265
|
+
filter_params = {
|
|
266
|
+
"ra": target.ra.deg,
|
|
267
|
+
"dec": target.dec.deg,
|
|
268
|
+
"radius": radius,
|
|
269
|
+
"separations": separations,
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
self._add_filter(Filter(name="position", mask=mask, params=filter_params))
|
|
273
|
+
return self
|
|
274
|
+
|
|
275
|
+
def revolution(self, revolutions: Union[int, str, list[Union[int, str]]]) -> "ScwQuery":
|
|
276
|
+
"""
|
|
277
|
+
Filter by revolution number(s).
|
|
278
|
+
|
|
279
|
+
Parameters
|
|
280
|
+
----------
|
|
281
|
+
revolutions : int, str, or list
|
|
282
|
+
Revolution number(s) as integer (255), 4-digit string ("0255"),
|
|
283
|
+
or list of mixed types
|
|
284
|
+
|
|
285
|
+
Returns
|
|
286
|
+
-------
|
|
287
|
+
ScwQuery
|
|
288
|
+
Self for method chaining
|
|
289
|
+
|
|
290
|
+
Examples
|
|
291
|
+
--------
|
|
292
|
+
>>> query.revolution(255)
|
|
293
|
+
>>> query.revolution("0255")
|
|
294
|
+
>>> query.revolution([255, "0256", 300])
|
|
295
|
+
"""
|
|
296
|
+
if not isinstance(revolutions, list):
|
|
297
|
+
revolutions = [revolutions]
|
|
298
|
+
|
|
299
|
+
rev_ints = []
|
|
300
|
+
for rev in revolutions:
|
|
301
|
+
if isinstance(rev, int):
|
|
302
|
+
rev_ints.append(rev)
|
|
303
|
+
elif isinstance(rev, str):
|
|
304
|
+
if len(rev) != 4:
|
|
305
|
+
raise ValueError(f"Revolution string must be 4 digits: '{rev}'")
|
|
306
|
+
try:
|
|
307
|
+
rev_ints.append(int(rev))
|
|
308
|
+
except ValueError:
|
|
309
|
+
raise ValueError(f"Invalid revolution string: '{rev}'")
|
|
310
|
+
else:
|
|
311
|
+
raise TypeError(f"Revolution must be int or str, got {type(rev)}")
|
|
312
|
+
|
|
313
|
+
mask = np.isin(self.catalog["REVOL"], rev_ints)
|
|
314
|
+
self._add_filter(Filter(name="revolution", mask=mask, params={"revolutions": rev_ints}))
|
|
315
|
+
return self
|
|
316
|
+
|
|
317
|
+
def get(self) -> Table:
|
|
318
|
+
"""
|
|
319
|
+
Apply all filters and return filtered catalog.
|
|
320
|
+
|
|
321
|
+
Returns
|
|
322
|
+
-------
|
|
323
|
+
Table
|
|
324
|
+
Filtered catalog as astropy Table
|
|
325
|
+
|
|
326
|
+
Notes
|
|
327
|
+
-----
|
|
328
|
+
This is typically the final call in a filter chain:
|
|
329
|
+
|
|
330
|
+
Examples
|
|
331
|
+
--------
|
|
332
|
+
>>> results = query.time(tstart=3000).quality(max_chi=2.0).get()
|
|
333
|
+
>>> print(len(results))
|
|
334
|
+
"""
|
|
335
|
+
combined_mask = self.mask.copy()
|
|
336
|
+
for filt in self._filters:
|
|
337
|
+
combined_mask &= filt.mask
|
|
338
|
+
return self.catalog[combined_mask]
|
|
339
|
+
|
|
340
|
+
def count(self) -> int:
|
|
341
|
+
"""
|
|
342
|
+
Count SCWs matching current filters.
|
|
343
|
+
|
|
344
|
+
Returns
|
|
345
|
+
-------
|
|
346
|
+
int
|
|
347
|
+
Number of matching SCWs
|
|
348
|
+
|
|
349
|
+
Examples
|
|
350
|
+
--------
|
|
351
|
+
>>> query.time(tstart=3000).count()
|
|
352
|
+
150
|
|
353
|
+
>>> # Faster than len(query.get()) for large catalogs
|
|
354
|
+
"""
|
|
355
|
+
return len(self.get())
|
|
356
|
+
|
|
357
|
+
def reset(self) -> "ScwQuery":
|
|
358
|
+
"""
|
|
359
|
+
Clear all filters and reset to full catalog.
|
|
360
|
+
|
|
361
|
+
Returns
|
|
362
|
+
-------
|
|
363
|
+
ScwQuery
|
|
364
|
+
Self for method chaining
|
|
365
|
+
|
|
366
|
+
Examples
|
|
367
|
+
--------
|
|
368
|
+
>>> query.time(tstart=3000).get() # First query
|
|
369
|
+
>>> query.reset() # Clear filters
|
|
370
|
+
>>> query.quality(max_chi=2.0).get() # New query
|
|
371
|
+
"""
|
|
372
|
+
self._filters.clear()
|
|
373
|
+
self._mask = None
|
|
374
|
+
return self
|
|
375
|
+
|
|
376
|
+
def _compute_detector_offsets(self, target: SkyCoord) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
377
|
+
"""
|
|
378
|
+
Compute detector Y/Z offsets using compute_detector_offset.
|
|
379
|
+
|
|
380
|
+
Parameters
|
|
381
|
+
----------
|
|
382
|
+
target : SkyCoord
|
|
383
|
+
Target sky position
|
|
384
|
+
|
|
385
|
+
Returns
|
|
386
|
+
-------
|
|
387
|
+
y_offset : ndarray
|
|
388
|
+
Y-axis offsets in degrees
|
|
389
|
+
z_offset : ndarray
|
|
390
|
+
Z-axis offsets in degrees
|
|
391
|
+
max_offset : ndarray
|
|
392
|
+
Maximum of |Y| and |Z| offsets
|
|
393
|
+
"""
|
|
394
|
+
y_off, z_off = compute_detector_offset(
|
|
395
|
+
target.ra.deg,
|
|
396
|
+
target.dec.deg,
|
|
397
|
+
self.catalog["RA_SCX"],
|
|
398
|
+
self.catalog["DEC_SCX"],
|
|
399
|
+
self.catalog["RA_SCZ"],
|
|
400
|
+
self.catalog["DEC_SCZ"],
|
|
401
|
+
)
|
|
402
|
+
max_off = np.maximum(np.abs(y_off), np.abs(z_off))
|
|
403
|
+
return y_off, z_off, max_off
|
|
404
|
+
|
|
405
|
+
def _add_filter(self, filter: Filter):
|
|
406
|
+
"""Replace existing filter with same name or add new filter"""
|
|
407
|
+
self._filters = [f for f in self._filters if f.name != filter.name]
|
|
408
|
+
self._filters.append(filter)
|
|
409
|
+
|
|
410
|
+
def _parse_time(self, time: Union[float, str]) -> float:
|
|
411
|
+
"""
|
|
412
|
+
Parse time to IJD format.
|
|
413
|
+
|
|
414
|
+
Parameters
|
|
415
|
+
----------
|
|
416
|
+
time : float or str
|
|
417
|
+
Time as IJD (< 51544), MJD (>= 51544), or ISO string
|
|
418
|
+
|
|
419
|
+
Returns
|
|
420
|
+
-------
|
|
421
|
+
float
|
|
422
|
+
Time in IJD format
|
|
423
|
+
"""
|
|
424
|
+
if isinstance(time, (int, float)):
|
|
425
|
+
return time if time < 51544 else time - 51544
|
|
426
|
+
if isinstance(time, str):
|
|
427
|
+
return utc2ijd(time)
|
|
428
|
+
raise TypeError(f"Invalid time type: {type(time)}")
|
|
429
|
+
|
|
430
|
+
def _parse_position(self, ra: Union[float, str], dec: Union[float, str]) -> SkyCoord:
|
|
431
|
+
"""
|
|
432
|
+
Parse coordinates to SkyCoord.
|
|
433
|
+
|
|
434
|
+
Parameters
|
|
435
|
+
----------
|
|
436
|
+
ra : float or str
|
|
437
|
+
Right ascension as degrees or HMS string
|
|
438
|
+
dec : float or str
|
|
439
|
+
Declination as degrees or DMS string
|
|
440
|
+
|
|
441
|
+
Returns
|
|
442
|
+
-------
|
|
443
|
+
SkyCoord
|
|
444
|
+
Parsed coordinate
|
|
445
|
+
"""
|
|
446
|
+
if isinstance(ra, (int, float)) and isinstance(dec, (int, float)):
|
|
447
|
+
return SkyCoord(ra, dec, unit="deg")
|
|
448
|
+
|
|
449
|
+
if isinstance(ra, str) and isinstance(dec, str):
|
|
450
|
+
try:
|
|
451
|
+
return SkyCoord(ra, dec, unit=(u.hourangle, u.deg))
|
|
452
|
+
except:
|
|
453
|
+
try:
|
|
454
|
+
return SkyCoord(ra, dec, unit="deg")
|
|
455
|
+
except Exception as e:
|
|
456
|
+
raise ValueError(f"Could not parse position: {ra}, {dec}") from e
|
|
457
|
+
|
|
458
|
+
raise TypeError(f"Invalid position types: {type(ra)}, {type(dec)}")
|
|
459
|
+
|
|
460
|
+
@property
|
|
461
|
+
def filters_summary(self) -> dict:
|
|
462
|
+
"""
|
|
463
|
+
Get summary of applied filters.
|
|
464
|
+
|
|
465
|
+
Returns
|
|
466
|
+
-------
|
|
467
|
+
dict
|
|
468
|
+
Dictionary mapping filter names to their parameters
|
|
469
|
+
"""
|
|
470
|
+
return {f.name: f.params for f in self._filters}
|
|
471
|
+
|
|
472
|
+
def get_offsets(self, ra: Union[float, str], dec: Union[float, str]) -> Table:
|
|
473
|
+
"""
|
|
474
|
+
Get filtered catalog with detector offsets computed.
|
|
475
|
+
|
|
476
|
+
Parameters
|
|
477
|
+
----------
|
|
478
|
+
ra : float or str
|
|
479
|
+
Right ascension
|
|
480
|
+
dec : float or str
|
|
481
|
+
Declination
|
|
482
|
+
|
|
483
|
+
Returns
|
|
484
|
+
-------
|
|
485
|
+
Table
|
|
486
|
+
Filtered catalog with Y_OFFSET, Z_OFFSET, MAX_OFFSET columns added
|
|
487
|
+
|
|
488
|
+
Examples
|
|
489
|
+
--------
|
|
490
|
+
>>> results = query.time(tstart=3000).get_offsets(ra=83.63, dec=22.01)
|
|
491
|
+
>>> fully_coded = results[results['MAX_OFFSET'] <= 4.0]
|
|
492
|
+
"""
|
|
493
|
+
target = self._parse_position(ra, dec)
|
|
494
|
+
y_off, z_off, max_off = self._compute_detector_offsets(target)
|
|
495
|
+
|
|
496
|
+
result = self.get()
|
|
497
|
+
combined_mask = self._get_combined_mask()
|
|
498
|
+
result["Y_OFFSET"] = y_off[combined_mask]
|
|
499
|
+
result["Z_OFFSET"] = z_off[combined_mask]
|
|
500
|
+
result["MAX_OFFSET"] = max_off[combined_mask]
|
|
501
|
+
return result
|
|
502
|
+
|
|
503
|
+
def _get_combined_mask(self) -> np.ndarray:
|
|
504
|
+
"""Get combined mask from all active filters"""
|
|
505
|
+
combined_mask = self.mask.copy()
|
|
506
|
+
for filt in self._filters:
|
|
507
|
+
combined_mask &= filt.mask
|
|
508
|
+
return combined_mask
|
|
509
|
+
|
|
510
|
+
def __repr__(self) -> str:
|
|
511
|
+
n_total = len(self.catalog)
|
|
512
|
+
n_selected = self.count()
|
|
513
|
+
return (
|
|
514
|
+
f"ScwQuery(catalog={self.catalog_path.name}, "
|
|
515
|
+
f"total={n_total}, selected={n_selected}, "
|
|
516
|
+
f"filters={list(self.filters_summary.keys())})"
|
|
517
|
+
)
|
isgri/catalog/wcs.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WCS coordinate transformations for celestial coordinates.
|
|
3
|
+
|
|
4
|
+
Implements spherical coordinate rotations following Calabretta & Greisen (2002),
|
|
5
|
+
"Representations of celestial coordinates in FITS", A&A 395, 1077-1122.
|
|
6
|
+
https://doi.org/10.1051/0004-6361:20021327
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Union
|
|
10
|
+
import numpy.typing as npt
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def spherical_to_cartesian(lon, lat):
|
|
15
|
+
"""
|
|
16
|
+
Convert spherical coordinates to Cartesian unit vectors.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
lon: Longitude in degrees
|
|
20
|
+
lat: Latitude in degrees
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
tuple: (x, y, z) Cartesian coordinates on unit sphere
|
|
24
|
+
"""
|
|
25
|
+
lon_rad = np.radians(lon)
|
|
26
|
+
lat_rad = np.radians(lat)
|
|
27
|
+
|
|
28
|
+
cos_lat = np.cos(lat_rad)
|
|
29
|
+
x = cos_lat * np.cos(lon_rad)
|
|
30
|
+
y = cos_lat * np.sin(lon_rad)
|
|
31
|
+
z = np.sin(lat_rad)
|
|
32
|
+
|
|
33
|
+
return x, y, z
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def cartesian_to_spherical(x, y, z):
|
|
37
|
+
"""
|
|
38
|
+
Convert Cartesian unit vectors to spherical coordinates.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
x, y, z: Cartesian coordinates
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
tuple: (lon, lat) in degrees
|
|
45
|
+
"""
|
|
46
|
+
# Clamp z to valid range for arcsin
|
|
47
|
+
z = np.clip(z, -1.0, 1.0)
|
|
48
|
+
|
|
49
|
+
lat = np.degrees(np.arcsin(z))
|
|
50
|
+
lon = np.degrees(np.arctan2(y, x))
|
|
51
|
+
|
|
52
|
+
return lon, lat
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def rotation_matrix(alpha_p, delta_p, phi_p=np.pi):
|
|
56
|
+
"""
|
|
57
|
+
Compute rotation matrix for coordinate transformation.
|
|
58
|
+
|
|
59
|
+
Following Calabretta & Greisen (2002), equations (5) and (7).
|
|
60
|
+
Assumes theta_0 = 90° (most common case).
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
alpha_p: Reference point RA in radians
|
|
64
|
+
delta_p: Reference point Dec in radians
|
|
65
|
+
phi_p: Native longitude of celestial pole (default: π for standard orientation)
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
ndarray: 3x3 rotation matrix
|
|
69
|
+
"""
|
|
70
|
+
sa = np.sin(alpha_p)
|
|
71
|
+
ca = np.cos(alpha_p)
|
|
72
|
+
sd = np.sin(delta_p)
|
|
73
|
+
cd = np.cos(delta_p)
|
|
74
|
+
sp = np.sin(phi_p)
|
|
75
|
+
cp = np.cos(phi_p)
|
|
76
|
+
|
|
77
|
+
# Rotation matrix from Calabretta & Greisen (2002), eq. (5)
|
|
78
|
+
R = np.array(
|
|
79
|
+
[
|
|
80
|
+
[-sa * sp - ca * cp * sd, ca * sp - sa * cp * sd, cp * cd],
|
|
81
|
+
[sa * cp - ca * sp * sd, -ca * cp - sa * sp * sd, sp * cd],
|
|
82
|
+
[ca * cd, sa * cd, sd],
|
|
83
|
+
]
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return R
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def celestial_to_native(lon, lat, crval, longpole=180.0):
|
|
90
|
+
"""
|
|
91
|
+
Transform from celestial (RA/Dec) to native spherical coordinates.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
lon: Celestial longitude (RA) in degrees
|
|
95
|
+
lat: Celestial latitude (Dec) in degrees
|
|
96
|
+
crval: Reference point [RA, Dec] in degrees
|
|
97
|
+
longpole: Native longitude of celestial north pole (default: 180°)
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
tuple: (phi, theta) native coordinates in degrees
|
|
101
|
+
"""
|
|
102
|
+
alpha_p = np.radians(crval[0])
|
|
103
|
+
delta_p = np.radians(crval[1])
|
|
104
|
+
phi_p = np.radians(longpole)
|
|
105
|
+
|
|
106
|
+
x, y, z = spherical_to_cartesian(lon, lat)
|
|
107
|
+
|
|
108
|
+
R = rotation_matrix(alpha_p, delta_p, phi_p)
|
|
109
|
+
x_rot = R[0, 0] * x + R[0, 1] * y + R[0, 2] * z
|
|
110
|
+
y_rot = R[1, 0] * x + R[1, 1] * y + R[1, 2] * z
|
|
111
|
+
z_rot = R[2, 0] * x + R[2, 1] * y + R[2, 2] * z
|
|
112
|
+
|
|
113
|
+
phi, theta = cartesian_to_spherical(x_rot, y_rot, z_rot)
|
|
114
|
+
|
|
115
|
+
return phi, theta
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def native_to_celestial(phi, theta, crval, longpole=180.0):
|
|
119
|
+
"""
|
|
120
|
+
Transform from native spherical to celestial (RA/Dec) coordinates.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
phi: Native longitude in degrees
|
|
124
|
+
theta: Native latitude in degrees
|
|
125
|
+
crval: Reference point [RA, Dec] in degrees
|
|
126
|
+
longpole: Native longitude of celestial north pole (default: 180°)
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
tuple: (lon, lat) celestial coordinates in degrees
|
|
130
|
+
"""
|
|
131
|
+
alpha_p = np.radians(crval[0])
|
|
132
|
+
delta_p = np.radians(crval[1])
|
|
133
|
+
phi_p = np.radians(longpole)
|
|
134
|
+
|
|
135
|
+
x, y, z = spherical_to_cartesian(phi, theta)
|
|
136
|
+
|
|
137
|
+
R = rotation_matrix(alpha_p, delta_p, phi_p).T # Transpose for inverse rotation
|
|
138
|
+
x_rot = R[0, 0] * x + R[0, 1] * y + R[0, 2] * z
|
|
139
|
+
y_rot = R[1, 0] * x + R[1, 1] * y + R[1, 2] * z
|
|
140
|
+
z_rot = R[2, 0] * x + R[2, 1] * y + R[2, 2] * z
|
|
141
|
+
|
|
142
|
+
lon, lat = cartesian_to_spherical(x_rot, y_rot, z_rot)
|
|
143
|
+
|
|
144
|
+
return lon, lat
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def compute_detector_offset(
|
|
148
|
+
src_ra: Union[float, npt.ArrayLike],
|
|
149
|
+
src_dec: Union[float, npt.ArrayLike],
|
|
150
|
+
pointing_ra: float,
|
|
151
|
+
pointing_dec: float,
|
|
152
|
+
z_ra: float,
|
|
153
|
+
z_dec: float,
|
|
154
|
+
) -> tuple[Union[float, np.ndarray], Union[float, np.ndarray]]:
|
|
155
|
+
"""
|
|
156
|
+
Compute source offset in INTEGRAL detector coordinates.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
src_ra: Source RA in degrees
|
|
160
|
+
src_dec: Source Dec in degrees
|
|
161
|
+
pointing_ra: Pointing axis RA in degrees
|
|
162
|
+
pointing_dec: Pointing axis Dec in degrees
|
|
163
|
+
z_ra: Z-axis RA in degrees
|
|
164
|
+
z_dec: Z-axis Dec in degrees
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
tuple: (y_offset, z_offset) in degrees (absolute values)
|
|
168
|
+
"""
|
|
169
|
+
# Transform Z-axis to native coordinates to get roll angle
|
|
170
|
+
scZ_phi, _ = celestial_to_native(z_ra, z_dec, [pointing_ra, pointing_dec])
|
|
171
|
+
roll = scZ_phi - 180.0
|
|
172
|
+
|
|
173
|
+
# Transform source to native coordinates
|
|
174
|
+
phi, theta = celestial_to_native(src_ra, src_dec, [pointing_ra, pointing_dec])
|
|
175
|
+
|
|
176
|
+
# Convert to detector coordinates
|
|
177
|
+
# theta is elevation from pointing axis
|
|
178
|
+
theta = 90.0 - theta
|
|
179
|
+
|
|
180
|
+
# phi is azimuth, correct for roll
|
|
181
|
+
phi = phi + 90.0 - roll
|
|
182
|
+
|
|
183
|
+
# Project onto detector Y and Z axes
|
|
184
|
+
theta_rad = np.radians(theta)
|
|
185
|
+
phi_rad = np.radians(phi)
|
|
186
|
+
|
|
187
|
+
y = np.degrees(np.arctan(np.tan(theta_rad) * np.cos(phi_rad)))
|
|
188
|
+
z = np.degrees(np.arctan(np.tan(theta_rad) * np.sin(phi_rad)))
|
|
189
|
+
|
|
190
|
+
return np.abs(y), np.abs(z)
|