isgri 0.4.0__py3-none-any.whl → 0.5.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.
isgri/catalog/scwquery.py CHANGED
@@ -1,517 +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
-
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
- )
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
+ )