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 CHANGED
@@ -0,0 +1 @@
1
+ from .__version__ import __version__
isgri/__version__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.5.0"
@@ -0,0 +1,3 @@
1
+ from .scwquery import ScwQuery
2
+
3
+ __all__ = ["ScwQuery"]
@@ -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
+ )