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/__init__.py +1 -0
- isgri/__version__.py +1 -0
- isgri/catalog/__init__.py +3 -3
- isgri/catalog/builder.py +90 -0
- isgri/catalog/scwquery.py +524 -517
- isgri/catalog/wcs.py +190 -190
- isgri/cli.py +224 -0
- isgri/config.py +151 -0
- isgri/utils/file_loaders.py +392 -389
- isgri/utils/lightcurve.py +409 -409
- isgri/utils/pif.py +286 -286
- isgri/utils/quality.py +389 -389
- isgri/utils/time_conversion.py +210 -210
- {isgri-0.4.0.dist-info → isgri-0.5.1.dist-info}/METADATA +68 -11
- isgri-0.5.1.dist-info/RECORD +19 -0
- {isgri-0.4.0.dist-info → isgri-0.5.1.dist-info}/WHEEL +1 -1
- isgri-0.5.1.dist-info/entry_points.txt +2 -0
- isgri-0.4.0.dist-info/RECORD +0 -14
- {isgri-0.4.0.dist-info → isgri-0.5.1.dist-info}/licenses/LICENSE +0 -0
isgri/utils/quality.py
CHANGED
|
@@ -1,389 +1,389 @@
|
|
|
1
|
-
"""
|
|
2
|
-
ISGRI Data Quality Metrics
|
|
3
|
-
===========================
|
|
4
|
-
|
|
5
|
-
Statistical quality metrics for INTEGRAL/ISGRI light curves.
|
|
6
|
-
|
|
7
|
-
The main metric is reduced chi-squared (chisq/dof), which tests whether
|
|
8
|
-
count rates are consistent with Poisson statistics. Values near 1.0
|
|
9
|
-
indicate stable background and no variable sources.
|
|
10
|
-
|
|
11
|
-
Classes
|
|
12
|
-
-------
|
|
13
|
-
QualityMetrics : Compute chi-squared metrics for light curves
|
|
14
|
-
|
|
15
|
-
Examples
|
|
16
|
-
--------
|
|
17
|
-
>>> from isgri.utils import LightCurve, QualityMetrics
|
|
18
|
-
>>>
|
|
19
|
-
>>> # Load light curve
|
|
20
|
-
>>> lc = LightCurve.load_data("events.fits")
|
|
21
|
-
>>>
|
|
22
|
-
>>> # Compute quality metrics
|
|
23
|
-
>>> qm = QualityMetrics(lc, binsize=1.0, emin=20, emax=100)
|
|
24
|
-
>>> chi = qm.raw_chi_squared()
|
|
25
|
-
>>> print(f"Raw chisq/dof = {chi:.2f}")
|
|
26
|
-
>>>
|
|
27
|
-
>>> # Sigma-clipped (removes outliers)
|
|
28
|
-
>>> chi_clip = qm.sigma_clip_chi_squared(sigma=3.0)
|
|
29
|
-
>>> print(f"Clipped chisq/dof = {chi_clip:.2f}")
|
|
30
|
-
>>>
|
|
31
|
-
>>> # GTI-filtered (only good time intervals)
|
|
32
|
-
>>> chi_gti = qm.gti_chi_squared()
|
|
33
|
-
>>> print(f"GTI chisq/dof = {chi_gti:.2f}")
|
|
34
|
-
|
|
35
|
-
"""
|
|
36
|
-
|
|
37
|
-
import numpy as np
|
|
38
|
-
from numpy.typing import NDArray
|
|
39
|
-
from typing import Optional, Tuple, Union
|
|
40
|
-
from .lightcurve import LightCurve
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
class QualityMetrics:
|
|
44
|
-
"""
|
|
45
|
-
Compute statistical quality metrics for ISGRI light curves.
|
|
46
|
-
|
|
47
|
-
Uses module-by-module light curves to compute chi-squared statistics.
|
|
48
|
-
Results are weighted by total counts per module.
|
|
49
|
-
|
|
50
|
-
Parameters
|
|
51
|
-
----------
|
|
52
|
-
lightcurve : LightCurve, optional
|
|
53
|
-
LightCurve instance to analyze
|
|
54
|
-
binsize : float, default 1.0
|
|
55
|
-
Bin size in seconds
|
|
56
|
-
emin : float, default 1.0
|
|
57
|
-
Minimum energy in keV
|
|
58
|
-
emax : float, default 1000.0
|
|
59
|
-
Maximum energy in keV
|
|
60
|
-
local_time : bool, default False
|
|
61
|
-
If True, use local time (seconds from T0).
|
|
62
|
-
If False, use IJD time. GTIs are always in IJD.
|
|
63
|
-
|
|
64
|
-
Attributes
|
|
65
|
-
----------
|
|
66
|
-
module_data : dict or None
|
|
67
|
-
Cached rebinned data {'time': array, 'counts': array}
|
|
68
|
-
|
|
69
|
-
Examples
|
|
70
|
-
--------
|
|
71
|
-
>>> lc = LightCurve.load_data("events.fits")
|
|
72
|
-
>>> qm = QualityMetrics(lc, binsize=1.0, emin=20, emax=100)
|
|
73
|
-
>>>
|
|
74
|
-
>>> # Compute various chi-squared metrics
|
|
75
|
-
>>> raw_chi = qm.raw_chi_squared()
|
|
76
|
-
>>> clip_chi = qm.sigma_clip_chi_squared(sigma=3.0)
|
|
77
|
-
>>> gti_chi = qm.gti_chi_squared()
|
|
78
|
-
>>>
|
|
79
|
-
>>> print(f"Raw: {raw_chi:.2f}, Clipped: {clip_chi:.2f}, GTI: {gti_chi:.2f}")
|
|
80
|
-
|
|
81
|
-
See Also
|
|
82
|
-
--------
|
|
83
|
-
raw_chi_squared : Basic chi-squared test
|
|
84
|
-
sigma_clip_chi_squared : Remove outliers before testing
|
|
85
|
-
gti_chi_squared : Test only good time intervals
|
|
86
|
-
"""
|
|
87
|
-
|
|
88
|
-
def __init__(
|
|
89
|
-
self,
|
|
90
|
-
lightcurve: Optional[LightCurve] = None,
|
|
91
|
-
binsize: float = 1.0,
|
|
92
|
-
emin: float = 1.0,
|
|
93
|
-
emax: float = 1000.0,
|
|
94
|
-
local_time: bool = False,
|
|
95
|
-
) -> None:
|
|
96
|
-
"""Initialize QualityMetrics instance."""
|
|
97
|
-
if lightcurve is not None and not isinstance(lightcurve, LightCurve):
|
|
98
|
-
raise TypeError(f"lightcurve must be LightCurve instance or None, got {type(lightcurve)}")
|
|
99
|
-
|
|
100
|
-
if binsize <= 0:
|
|
101
|
-
raise ValueError(f"binsize must be positive, got {binsize}")
|
|
102
|
-
|
|
103
|
-
if emin >= emax:
|
|
104
|
-
raise ValueError(f"emin ({emin}) must be less than emax ({emax})")
|
|
105
|
-
|
|
106
|
-
if emin < 0:
|
|
107
|
-
raise ValueError(f"emin must be non-negative, got {emin}")
|
|
108
|
-
|
|
109
|
-
self.lightcurve = lightcurve
|
|
110
|
-
self.binsize = binsize
|
|
111
|
-
self.emin = emin
|
|
112
|
-
self.emax = emax
|
|
113
|
-
self.local_time = local_time
|
|
114
|
-
self.module_data: Optional[dict] = None
|
|
115
|
-
|
|
116
|
-
def __repr__(self) -> str:
|
|
117
|
-
"""String representation."""
|
|
118
|
-
return (
|
|
119
|
-
f"QualityMetrics(binsize={self.binsize}s, "
|
|
120
|
-
f"energy=({self.emin:.1f}-{self.emax:.1f}) keV, "
|
|
121
|
-
f"lightcurve={'set' if self.lightcurve else 'None'})"
|
|
122
|
-
)
|
|
123
|
-
|
|
124
|
-
def _compute_counts(self) -> dict:
|
|
125
|
-
"""
|
|
126
|
-
Compute or retrieve cached rebinned counts for all modules.
|
|
127
|
-
|
|
128
|
-
Returns
|
|
129
|
-
-------
|
|
130
|
-
dict
|
|
131
|
-
Dictionary with 'time' (ndarray) and 'counts' (ndarray, shape (8, n_bins))
|
|
132
|
-
|
|
133
|
-
Raises
|
|
134
|
-
------
|
|
135
|
-
ValueError
|
|
136
|
-
If lightcurve is not set
|
|
137
|
-
"""
|
|
138
|
-
if self.lightcurve is None:
|
|
139
|
-
raise ValueError("Lightcurve must be set before computing counts")
|
|
140
|
-
|
|
141
|
-
if self.module_data is not None:
|
|
142
|
-
return self.module_data
|
|
143
|
-
|
|
144
|
-
time, counts = self.lightcurve.rebin_by_modules(
|
|
145
|
-
binsize=self.binsize,
|
|
146
|
-
emin=self.emin,
|
|
147
|
-
emax=self.emax,
|
|
148
|
-
local_time=self.local_time,
|
|
149
|
-
)
|
|
150
|
-
|
|
151
|
-
self.module_data = {
|
|
152
|
-
"time": time,
|
|
153
|
-
"counts": np.asarray(counts), # Shape: (8, n_bins)
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
return self.module_data
|
|
157
|
-
|
|
158
|
-
def _compute_chi_squared_red(
|
|
159
|
-
self,
|
|
160
|
-
counts: NDArray[np.float64],
|
|
161
|
-
return_all: bool = False,
|
|
162
|
-
) -> Union[float, Tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.float64]]]:
|
|
163
|
-
"""
|
|
164
|
-
Compute reduced chi-squared for count data.
|
|
165
|
-
|
|
166
|
-
Parameters
|
|
167
|
-
----------
|
|
168
|
-
counts : ndarray
|
|
169
|
-
Count array(s). Shape: (n_modules, n_bins) or (n_bins,)
|
|
170
|
-
return_all : bool, default False
|
|
171
|
-
If True, return (chi_squared, dof, total_counts) per module.
|
|
172
|
-
If False, return weighted mean chi-squared.
|
|
173
|
-
|
|
174
|
-
Returns
|
|
175
|
-
-------
|
|
176
|
-
chi_squared_red : float
|
|
177
|
-
Weighted mean of chisq/dof across modules (if return_all=False)
|
|
178
|
-
chi_squared, dof, total_counts : tuple of ndarrays
|
|
179
|
-
Per-module statistics (if return_all=True)
|
|
180
|
-
|
|
181
|
-
Notes
|
|
182
|
-
-----
|
|
183
|
-
- Empty bins (counts=0) are treated as NaN and excluded
|
|
184
|
-
- DOF = (number of non-empty bins) - 1
|
|
185
|
-
- Weighting by total counts gives more influence to active modules
|
|
186
|
-
"""
|
|
187
|
-
counts = np.asarray(counts)
|
|
188
|
-
|
|
189
|
-
# Replace zeros with NaN (exclude empty bins)
|
|
190
|
-
counts = np.where(counts == 0, np.nan, counts)
|
|
191
|
-
|
|
192
|
-
# Compute mean and chi-squared per module
|
|
193
|
-
mean_counts = np.nanmean(counts, axis=-1, keepdims=True)
|
|
194
|
-
chi_squared = np.nansum((counts - mean_counts) ** 2 / mean_counts, axis=-1)
|
|
195
|
-
|
|
196
|
-
# DOF = number of non-empty bins minus 1
|
|
197
|
-
nan_mask = ~np.isnan(counts)
|
|
198
|
-
dof = np.sum(nan_mask, axis=-1) - 1
|
|
199
|
-
total_counts = np.nansum(counts, axis=-1)
|
|
200
|
-
|
|
201
|
-
if return_all:
|
|
202
|
-
return chi_squared, dof, total_counts
|
|
203
|
-
|
|
204
|
-
# Return weighted mean
|
|
205
|
-
if np.sum(total_counts) == 0 or np.all(dof <= 0):
|
|
206
|
-
return np.nan
|
|
207
|
-
|
|
208
|
-
# Weight by total counts (more counts = more reliable chi-squared)
|
|
209
|
-
valid_mask = dof > 0
|
|
210
|
-
chi_squared_red = chi_squared[valid_mask] / dof[valid_mask]
|
|
211
|
-
weights = total_counts[valid_mask]
|
|
212
|
-
|
|
213
|
-
return np.average(chi_squared_red, weights=weights)
|
|
214
|
-
|
|
215
|
-
def raw_chi_squared(
|
|
216
|
-
self,
|
|
217
|
-
counts: Optional[NDArray[np.float64]] = None,
|
|
218
|
-
return_all: bool = False,
|
|
219
|
-
) -> Union[float, Tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.float64]]]:
|
|
220
|
-
"""
|
|
221
|
-
Compute raw reduced chi-squared (no filtering).
|
|
222
|
-
|
|
223
|
-
Tests whether count rates are consistent with Poisson statistics.
|
|
224
|
-
Values near 1.0 indicate stable, constant background.
|
|
225
|
-
|
|
226
|
-
Parameters
|
|
227
|
-
----------
|
|
228
|
-
counts : ndarray, optional
|
|
229
|
-
Count array(s) to analyze. If None, uses cached module data.
|
|
230
|
-
return_all : bool, default False
|
|
231
|
-
If True, return per-module results. If False, return weighted mean.
|
|
232
|
-
|
|
233
|
-
Returns
|
|
234
|
-
-------
|
|
235
|
-
chi_squared_red : float
|
|
236
|
-
Reduced chi-squared (chisq/dof)
|
|
237
|
-
|
|
238
|
-
Examples
|
|
239
|
-
--------
|
|
240
|
-
>>> qm = QualityMetrics(lc, binsize=1.0, emin=20, emax=100)
|
|
241
|
-
>>> chi = qm.raw_chi_squared()
|
|
242
|
-
>>> print(f"chisq/dof = {chi:.2f}")
|
|
243
|
-
|
|
244
|
-
>>> # Get per-module results
|
|
245
|
-
>>> chi_vals, dof, counts = qm.raw_chi_squared(return_all=True)
|
|
246
|
-
>>> for i, (c, d) in enumerate(zip(chi_vals, dof)):
|
|
247
|
-
... print(f"Module {i}: chisq = {c:.1f}, dof = {d}")
|
|
248
|
-
"""
|
|
249
|
-
if counts is None:
|
|
250
|
-
counts = self._compute_counts()["counts"]
|
|
251
|
-
|
|
252
|
-
return self._compute_chi_squared_red(counts, return_all=return_all)
|
|
253
|
-
|
|
254
|
-
def sigma_clip_chi_squared(
|
|
255
|
-
self,
|
|
256
|
-
sigma: float = 3.0,
|
|
257
|
-
counts: Optional[NDArray[np.float64]] = None,
|
|
258
|
-
return_all: bool = False,
|
|
259
|
-
) -> Union[float, Tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.float64]]]:
|
|
260
|
-
"""
|
|
261
|
-
Compute sigma-clipped reduced chi-squared.
|
|
262
|
-
|
|
263
|
-
Removes outlier bins (>sigma standard deviations from mean)
|
|
264
|
-
before computing chi-squared. Useful for detecting transient
|
|
265
|
-
flares or background instabilities.
|
|
266
|
-
|
|
267
|
-
Parameters
|
|
268
|
-
----------
|
|
269
|
-
sigma : float, default 3.0
|
|
270
|
-
Sigma clipping threshold in standard deviations
|
|
271
|
-
counts : ndarray, optional
|
|
272
|
-
Count array(s) to analyze. If None, uses cached module data.
|
|
273
|
-
return_all : bool, default False
|
|
274
|
-
If True, return per-module results.
|
|
275
|
-
|
|
276
|
-
Returns
|
|
277
|
-
-------
|
|
278
|
-
chi_squared_red : float
|
|
279
|
-
Reduced chi-squared after clipping outliers
|
|
280
|
-
|
|
281
|
-
Examples
|
|
282
|
-
--------
|
|
283
|
-
>>> qm = QualityMetrics(lc, binsize=1.0, emin=20, emax=100)
|
|
284
|
-
>>>
|
|
285
|
-
>>> # Conservative clipping (remove extreme outliers)
|
|
286
|
-
>>> chi_3sig = qm.sigma_clip_chi_squared(sigma=3.0)
|
|
287
|
-
>>>
|
|
288
|
-
>>> # Aggressive clipping (remove moderate outliers)
|
|
289
|
-
>>> chi_1sig = qm.sigma_clip_chi_squared(sigma=1.0)
|
|
290
|
-
>>>
|
|
291
|
-
>>> print(f"3sigma: {chi_3sig:.2f}, 1sigma: {chi_1sig:.2f}")
|
|
292
|
-
|
|
293
|
-
Notes
|
|
294
|
-
-----
|
|
295
|
-
Lower chi-squared after clipping indicates presence of outliers
|
|
296
|
-
(flares, background jumps, etc.)
|
|
297
|
-
"""
|
|
298
|
-
if sigma <= 0:
|
|
299
|
-
raise ValueError(f"sigma must be positive, got {sigma}")
|
|
300
|
-
|
|
301
|
-
if counts is None:
|
|
302
|
-
counts = self._compute_counts()["counts"]
|
|
303
|
-
|
|
304
|
-
# Compute mean and std per module
|
|
305
|
-
mean_count = np.nanmean(counts, axis=-1, keepdims=True)
|
|
306
|
-
std_count = np.nanstd(counts, axis=-1, keepdims=True)
|
|
307
|
-
|
|
308
|
-
# Mask outliers
|
|
309
|
-
mask = np.abs(counts - mean_count) < sigma * std_count
|
|
310
|
-
filtered_counts = np.where(mask, counts, np.nan)
|
|
311
|
-
|
|
312
|
-
return self._compute_chi_squared_red(filtered_counts, return_all=return_all)
|
|
313
|
-
|
|
314
|
-
def gti_chi_squared(
|
|
315
|
-
self,
|
|
316
|
-
time: Optional[NDArray[np.float64]] = None,
|
|
317
|
-
counts: Optional[NDArray[np.float64]] = None,
|
|
318
|
-
gtis: Optional[NDArray[np.float64]] = None,
|
|
319
|
-
return_all: bool = False,
|
|
320
|
-
) -> Union[float, Tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.float64]]]:
|
|
321
|
-
"""
|
|
322
|
-
Compute GTI-filtered reduced chi-squared.
|
|
323
|
-
|
|
324
|
-
Only uses bins within Good Time Intervals (GTIs).
|
|
325
|
-
Useful for excluding known bad data periods.
|
|
326
|
-
|
|
327
|
-
Parameters
|
|
328
|
-
----------
|
|
329
|
-
time : ndarray, optional
|
|
330
|
-
Time array. If None, uses cached module data.
|
|
331
|
-
counts : ndarray, optional
|
|
332
|
-
Count array(s). If None, uses cached module data.
|
|
333
|
-
gtis : ndarray, optional
|
|
334
|
-
Good Time Intervals (N, 2) array in IJD.
|
|
335
|
-
If None, uses lightcurve.gtis.
|
|
336
|
-
return_all : bool, default False
|
|
337
|
-
If True, return per-module results.
|
|
338
|
-
|
|
339
|
-
Returns
|
|
340
|
-
-------
|
|
341
|
-
chi_squared_red : float
|
|
342
|
-
Reduced chi-squared within GTIs only
|
|
343
|
-
|
|
344
|
-
Raises
|
|
345
|
-
------
|
|
346
|
-
ValueError
|
|
347
|
-
If no overlap between GTIs and time range
|
|
348
|
-
|
|
349
|
-
Examples
|
|
350
|
-
--------
|
|
351
|
-
>>> qm = QualityMetrics(lc, binsize=1.0, emin=20, emax=100)
|
|
352
|
-
>>> chi_gti = qm.gti_chi_squared()
|
|
353
|
-
>>> print(f"GTI-filtered chisq/dof = {chi_gti:.2f}")
|
|
354
|
-
>>>
|
|
355
|
-
>>> # Use custom GTIs
|
|
356
|
-
>>> custom_gtis = np.array([[3000.0, 3100.0], [3200.0, 3300.0]])
|
|
357
|
-
>>> chi_custom = qm.gti_chi_squared(gtis=custom_gtis)
|
|
358
|
-
|
|
359
|
-
Notes
|
|
360
|
-
-----
|
|
361
|
-
GTIs are always in IJD format, regardless of local_time setting.
|
|
362
|
-
Time array must be converted to IJD for comparison.
|
|
363
|
-
"""
|
|
364
|
-
if counts is None or time is None:
|
|
365
|
-
data = self._compute_counts()
|
|
366
|
-
time, counts = data["time"], data["counts"]
|
|
367
|
-
|
|
368
|
-
if gtis is None:
|
|
369
|
-
if self.lightcurve is None:
|
|
370
|
-
raise ValueError("Must provide gtis or set lightcurve")
|
|
371
|
-
gtis = self.lightcurve.gtis
|
|
372
|
-
|
|
373
|
-
# Check for overlap
|
|
374
|
-
if gtis[0, 0] > time[-1] or gtis[-1, 1] < time[0]:
|
|
375
|
-
raise ValueError(
|
|
376
|
-
f"No overlap between GTIs ({gtis[0,0]:.1f}-{gtis[-1,1]:.1f}) "
|
|
377
|
-
f"and time range ({time[0]:.1f}-{time[-1]:.1f}). "
|
|
378
|
-
"Verify time is in IJD format."
|
|
379
|
-
)
|
|
380
|
-
|
|
381
|
-
# Create GTI mask
|
|
382
|
-
gti_mask = np.zeros_like(time, dtype=bool)
|
|
383
|
-
for gti_start, gti_stop in gtis:
|
|
384
|
-
gti_mask |= (time >= gti_start) & (time <= gti_stop)
|
|
385
|
-
|
|
386
|
-
# Apply mask (set non-GTI bins to NaN)
|
|
387
|
-
filtered_counts = np.where(gti_mask, counts, np.nan)
|
|
388
|
-
|
|
389
|
-
return self._compute_chi_squared_red(filtered_counts, return_all=return_all)
|
|
1
|
+
"""
|
|
2
|
+
ISGRI Data Quality Metrics
|
|
3
|
+
===========================
|
|
4
|
+
|
|
5
|
+
Statistical quality metrics for INTEGRAL/ISGRI light curves.
|
|
6
|
+
|
|
7
|
+
The main metric is reduced chi-squared (chisq/dof), which tests whether
|
|
8
|
+
count rates are consistent with Poisson statistics. Values near 1.0
|
|
9
|
+
indicate stable background and no variable sources.
|
|
10
|
+
|
|
11
|
+
Classes
|
|
12
|
+
-------
|
|
13
|
+
QualityMetrics : Compute chi-squared metrics for light curves
|
|
14
|
+
|
|
15
|
+
Examples
|
|
16
|
+
--------
|
|
17
|
+
>>> from isgri.utils import LightCurve, QualityMetrics
|
|
18
|
+
>>>
|
|
19
|
+
>>> # Load light curve
|
|
20
|
+
>>> lc = LightCurve.load_data("events.fits")
|
|
21
|
+
>>>
|
|
22
|
+
>>> # Compute quality metrics
|
|
23
|
+
>>> qm = QualityMetrics(lc, binsize=1.0, emin=20, emax=100)
|
|
24
|
+
>>> chi = qm.raw_chi_squared()
|
|
25
|
+
>>> print(f"Raw chisq/dof = {chi:.2f}")
|
|
26
|
+
>>>
|
|
27
|
+
>>> # Sigma-clipped (removes outliers)
|
|
28
|
+
>>> chi_clip = qm.sigma_clip_chi_squared(sigma=3.0)
|
|
29
|
+
>>> print(f"Clipped chisq/dof = {chi_clip:.2f}")
|
|
30
|
+
>>>
|
|
31
|
+
>>> # GTI-filtered (only good time intervals)
|
|
32
|
+
>>> chi_gti = qm.gti_chi_squared()
|
|
33
|
+
>>> print(f"GTI chisq/dof = {chi_gti:.2f}")
|
|
34
|
+
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import numpy as np
|
|
38
|
+
from numpy.typing import NDArray
|
|
39
|
+
from typing import Optional, Tuple, Union
|
|
40
|
+
from .lightcurve import LightCurve
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class QualityMetrics:
|
|
44
|
+
"""
|
|
45
|
+
Compute statistical quality metrics for ISGRI light curves.
|
|
46
|
+
|
|
47
|
+
Uses module-by-module light curves to compute chi-squared statistics.
|
|
48
|
+
Results are weighted by total counts per module.
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
lightcurve : LightCurve, optional
|
|
53
|
+
LightCurve instance to analyze
|
|
54
|
+
binsize : float, default 1.0
|
|
55
|
+
Bin size in seconds
|
|
56
|
+
emin : float, default 1.0
|
|
57
|
+
Minimum energy in keV
|
|
58
|
+
emax : float, default 1000.0
|
|
59
|
+
Maximum energy in keV
|
|
60
|
+
local_time : bool, default False
|
|
61
|
+
If True, use local time (seconds from T0).
|
|
62
|
+
If False, use IJD time. GTIs are always in IJD.
|
|
63
|
+
|
|
64
|
+
Attributes
|
|
65
|
+
----------
|
|
66
|
+
module_data : dict or None
|
|
67
|
+
Cached rebinned data {'time': array, 'counts': array}
|
|
68
|
+
|
|
69
|
+
Examples
|
|
70
|
+
--------
|
|
71
|
+
>>> lc = LightCurve.load_data("events.fits")
|
|
72
|
+
>>> qm = QualityMetrics(lc, binsize=1.0, emin=20, emax=100)
|
|
73
|
+
>>>
|
|
74
|
+
>>> # Compute various chi-squared metrics
|
|
75
|
+
>>> raw_chi = qm.raw_chi_squared()
|
|
76
|
+
>>> clip_chi = qm.sigma_clip_chi_squared(sigma=3.0)
|
|
77
|
+
>>> gti_chi = qm.gti_chi_squared()
|
|
78
|
+
>>>
|
|
79
|
+
>>> print(f"Raw: {raw_chi:.2f}, Clipped: {clip_chi:.2f}, GTI: {gti_chi:.2f}")
|
|
80
|
+
|
|
81
|
+
See Also
|
|
82
|
+
--------
|
|
83
|
+
raw_chi_squared : Basic chi-squared test
|
|
84
|
+
sigma_clip_chi_squared : Remove outliers before testing
|
|
85
|
+
gti_chi_squared : Test only good time intervals
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
lightcurve: Optional[LightCurve] = None,
|
|
91
|
+
binsize: float = 1.0,
|
|
92
|
+
emin: float = 1.0,
|
|
93
|
+
emax: float = 1000.0,
|
|
94
|
+
local_time: bool = False,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Initialize QualityMetrics instance."""
|
|
97
|
+
if lightcurve is not None and not isinstance(lightcurve, LightCurve):
|
|
98
|
+
raise TypeError(f"lightcurve must be LightCurve instance or None, got {type(lightcurve)}")
|
|
99
|
+
|
|
100
|
+
if binsize <= 0:
|
|
101
|
+
raise ValueError(f"binsize must be positive, got {binsize}")
|
|
102
|
+
|
|
103
|
+
if emin >= emax:
|
|
104
|
+
raise ValueError(f"emin ({emin}) must be less than emax ({emax})")
|
|
105
|
+
|
|
106
|
+
if emin < 0:
|
|
107
|
+
raise ValueError(f"emin must be non-negative, got {emin}")
|
|
108
|
+
|
|
109
|
+
self.lightcurve = lightcurve
|
|
110
|
+
self.binsize = binsize
|
|
111
|
+
self.emin = emin
|
|
112
|
+
self.emax = emax
|
|
113
|
+
self.local_time = local_time
|
|
114
|
+
self.module_data: Optional[dict] = None
|
|
115
|
+
|
|
116
|
+
def __repr__(self) -> str:
|
|
117
|
+
"""String representation."""
|
|
118
|
+
return (
|
|
119
|
+
f"QualityMetrics(binsize={self.binsize}s, "
|
|
120
|
+
f"energy=({self.emin:.1f}-{self.emax:.1f}) keV, "
|
|
121
|
+
f"lightcurve={'set' if self.lightcurve else 'None'})"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def _compute_counts(self) -> dict:
|
|
125
|
+
"""
|
|
126
|
+
Compute or retrieve cached rebinned counts for all modules.
|
|
127
|
+
|
|
128
|
+
Returns
|
|
129
|
+
-------
|
|
130
|
+
dict
|
|
131
|
+
Dictionary with 'time' (ndarray) and 'counts' (ndarray, shape (8, n_bins))
|
|
132
|
+
|
|
133
|
+
Raises
|
|
134
|
+
------
|
|
135
|
+
ValueError
|
|
136
|
+
If lightcurve is not set
|
|
137
|
+
"""
|
|
138
|
+
if self.lightcurve is None:
|
|
139
|
+
raise ValueError("Lightcurve must be set before computing counts")
|
|
140
|
+
|
|
141
|
+
if self.module_data is not None:
|
|
142
|
+
return self.module_data
|
|
143
|
+
|
|
144
|
+
time, counts = self.lightcurve.rebin_by_modules(
|
|
145
|
+
binsize=self.binsize,
|
|
146
|
+
emin=self.emin,
|
|
147
|
+
emax=self.emax,
|
|
148
|
+
local_time=self.local_time,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
self.module_data = {
|
|
152
|
+
"time": time,
|
|
153
|
+
"counts": np.asarray(counts), # Shape: (8, n_bins)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return self.module_data
|
|
157
|
+
|
|
158
|
+
def _compute_chi_squared_red(
|
|
159
|
+
self,
|
|
160
|
+
counts: NDArray[np.float64],
|
|
161
|
+
return_all: bool = False,
|
|
162
|
+
) -> Union[float, Tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.float64]]]:
|
|
163
|
+
"""
|
|
164
|
+
Compute reduced chi-squared for count data.
|
|
165
|
+
|
|
166
|
+
Parameters
|
|
167
|
+
----------
|
|
168
|
+
counts : ndarray
|
|
169
|
+
Count array(s). Shape: (n_modules, n_bins) or (n_bins,)
|
|
170
|
+
return_all : bool, default False
|
|
171
|
+
If True, return (chi_squared, dof, total_counts) per module.
|
|
172
|
+
If False, return weighted mean chi-squared.
|
|
173
|
+
|
|
174
|
+
Returns
|
|
175
|
+
-------
|
|
176
|
+
chi_squared_red : float
|
|
177
|
+
Weighted mean of chisq/dof across modules (if return_all=False)
|
|
178
|
+
chi_squared, dof, total_counts : tuple of ndarrays
|
|
179
|
+
Per-module statistics (if return_all=True)
|
|
180
|
+
|
|
181
|
+
Notes
|
|
182
|
+
-----
|
|
183
|
+
- Empty bins (counts=0) are treated as NaN and excluded
|
|
184
|
+
- DOF = (number of non-empty bins) - 1
|
|
185
|
+
- Weighting by total counts gives more influence to active modules
|
|
186
|
+
"""
|
|
187
|
+
counts = np.asarray(counts)
|
|
188
|
+
|
|
189
|
+
# Replace zeros with NaN (exclude empty bins)
|
|
190
|
+
counts = np.where(counts == 0, np.nan, counts)
|
|
191
|
+
|
|
192
|
+
# Compute mean and chi-squared per module
|
|
193
|
+
mean_counts = np.nanmean(counts, axis=-1, keepdims=True)
|
|
194
|
+
chi_squared = np.nansum((counts - mean_counts) ** 2 / mean_counts, axis=-1)
|
|
195
|
+
|
|
196
|
+
# DOF = number of non-empty bins minus 1
|
|
197
|
+
nan_mask = ~np.isnan(counts)
|
|
198
|
+
dof = np.sum(nan_mask, axis=-1) - 1
|
|
199
|
+
total_counts = np.nansum(counts, axis=-1)
|
|
200
|
+
|
|
201
|
+
if return_all:
|
|
202
|
+
return chi_squared, dof, total_counts
|
|
203
|
+
|
|
204
|
+
# Return weighted mean
|
|
205
|
+
if np.sum(total_counts) == 0 or np.all(dof <= 0):
|
|
206
|
+
return np.nan
|
|
207
|
+
|
|
208
|
+
# Weight by total counts (more counts = more reliable chi-squared)
|
|
209
|
+
valid_mask = dof > 0
|
|
210
|
+
chi_squared_red = chi_squared[valid_mask] / dof[valid_mask]
|
|
211
|
+
weights = total_counts[valid_mask]
|
|
212
|
+
|
|
213
|
+
return np.average(chi_squared_red, weights=weights)
|
|
214
|
+
|
|
215
|
+
def raw_chi_squared(
|
|
216
|
+
self,
|
|
217
|
+
counts: Optional[NDArray[np.float64]] = None,
|
|
218
|
+
return_all: bool = False,
|
|
219
|
+
) -> Union[float, Tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.float64]]]:
|
|
220
|
+
"""
|
|
221
|
+
Compute raw reduced chi-squared (no filtering).
|
|
222
|
+
|
|
223
|
+
Tests whether count rates are consistent with Poisson statistics.
|
|
224
|
+
Values near 1.0 indicate stable, constant background.
|
|
225
|
+
|
|
226
|
+
Parameters
|
|
227
|
+
----------
|
|
228
|
+
counts : ndarray, optional
|
|
229
|
+
Count array(s) to analyze. If None, uses cached module data.
|
|
230
|
+
return_all : bool, default False
|
|
231
|
+
If True, return per-module results. If False, return weighted mean.
|
|
232
|
+
|
|
233
|
+
Returns
|
|
234
|
+
-------
|
|
235
|
+
chi_squared_red : float
|
|
236
|
+
Reduced chi-squared (chisq/dof)
|
|
237
|
+
|
|
238
|
+
Examples
|
|
239
|
+
--------
|
|
240
|
+
>>> qm = QualityMetrics(lc, binsize=1.0, emin=20, emax=100)
|
|
241
|
+
>>> chi = qm.raw_chi_squared()
|
|
242
|
+
>>> print(f"chisq/dof = {chi:.2f}")
|
|
243
|
+
|
|
244
|
+
>>> # Get per-module results
|
|
245
|
+
>>> chi_vals, dof, counts = qm.raw_chi_squared(return_all=True)
|
|
246
|
+
>>> for i, (c, d) in enumerate(zip(chi_vals, dof)):
|
|
247
|
+
... print(f"Module {i}: chisq = {c:.1f}, dof = {d}")
|
|
248
|
+
"""
|
|
249
|
+
if counts is None:
|
|
250
|
+
counts = self._compute_counts()["counts"]
|
|
251
|
+
|
|
252
|
+
return self._compute_chi_squared_red(counts, return_all=return_all)
|
|
253
|
+
|
|
254
|
+
def sigma_clip_chi_squared(
|
|
255
|
+
self,
|
|
256
|
+
sigma: float = 3.0,
|
|
257
|
+
counts: Optional[NDArray[np.float64]] = None,
|
|
258
|
+
return_all: bool = False,
|
|
259
|
+
) -> Union[float, Tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.float64]]]:
|
|
260
|
+
"""
|
|
261
|
+
Compute sigma-clipped reduced chi-squared.
|
|
262
|
+
|
|
263
|
+
Removes outlier bins (>sigma standard deviations from mean)
|
|
264
|
+
before computing chi-squared. Useful for detecting transient
|
|
265
|
+
flares or background instabilities.
|
|
266
|
+
|
|
267
|
+
Parameters
|
|
268
|
+
----------
|
|
269
|
+
sigma : float, default 3.0
|
|
270
|
+
Sigma clipping threshold in standard deviations
|
|
271
|
+
counts : ndarray, optional
|
|
272
|
+
Count array(s) to analyze. If None, uses cached module data.
|
|
273
|
+
return_all : bool, default False
|
|
274
|
+
If True, return per-module results.
|
|
275
|
+
|
|
276
|
+
Returns
|
|
277
|
+
-------
|
|
278
|
+
chi_squared_red : float
|
|
279
|
+
Reduced chi-squared after clipping outliers
|
|
280
|
+
|
|
281
|
+
Examples
|
|
282
|
+
--------
|
|
283
|
+
>>> qm = QualityMetrics(lc, binsize=1.0, emin=20, emax=100)
|
|
284
|
+
>>>
|
|
285
|
+
>>> # Conservative clipping (remove extreme outliers)
|
|
286
|
+
>>> chi_3sig = qm.sigma_clip_chi_squared(sigma=3.0)
|
|
287
|
+
>>>
|
|
288
|
+
>>> # Aggressive clipping (remove moderate outliers)
|
|
289
|
+
>>> chi_1sig = qm.sigma_clip_chi_squared(sigma=1.0)
|
|
290
|
+
>>>
|
|
291
|
+
>>> print(f"3sigma: {chi_3sig:.2f}, 1sigma: {chi_1sig:.2f}")
|
|
292
|
+
|
|
293
|
+
Notes
|
|
294
|
+
-----
|
|
295
|
+
Lower chi-squared after clipping indicates presence of outliers
|
|
296
|
+
(flares, background jumps, etc.)
|
|
297
|
+
"""
|
|
298
|
+
if sigma <= 0:
|
|
299
|
+
raise ValueError(f"sigma must be positive, got {sigma}")
|
|
300
|
+
|
|
301
|
+
if counts is None:
|
|
302
|
+
counts = self._compute_counts()["counts"]
|
|
303
|
+
|
|
304
|
+
# Compute mean and std per module
|
|
305
|
+
mean_count = np.nanmean(counts, axis=-1, keepdims=True)
|
|
306
|
+
std_count = np.nanstd(counts, axis=-1, keepdims=True)
|
|
307
|
+
|
|
308
|
+
# Mask outliers
|
|
309
|
+
mask = np.abs(counts - mean_count) < sigma * std_count
|
|
310
|
+
filtered_counts = np.where(mask, counts, np.nan)
|
|
311
|
+
|
|
312
|
+
return self._compute_chi_squared_red(filtered_counts, return_all=return_all)
|
|
313
|
+
|
|
314
|
+
def gti_chi_squared(
|
|
315
|
+
self,
|
|
316
|
+
time: Optional[NDArray[np.float64]] = None,
|
|
317
|
+
counts: Optional[NDArray[np.float64]] = None,
|
|
318
|
+
gtis: Optional[NDArray[np.float64]] = None,
|
|
319
|
+
return_all: bool = False,
|
|
320
|
+
) -> Union[float, Tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.float64]]]:
|
|
321
|
+
"""
|
|
322
|
+
Compute GTI-filtered reduced chi-squared.
|
|
323
|
+
|
|
324
|
+
Only uses bins within Good Time Intervals (GTIs).
|
|
325
|
+
Useful for excluding known bad data periods.
|
|
326
|
+
|
|
327
|
+
Parameters
|
|
328
|
+
----------
|
|
329
|
+
time : ndarray, optional
|
|
330
|
+
Time array. If None, uses cached module data.
|
|
331
|
+
counts : ndarray, optional
|
|
332
|
+
Count array(s). If None, uses cached module data.
|
|
333
|
+
gtis : ndarray, optional
|
|
334
|
+
Good Time Intervals (N, 2) array in IJD.
|
|
335
|
+
If None, uses lightcurve.gtis.
|
|
336
|
+
return_all : bool, default False
|
|
337
|
+
If True, return per-module results.
|
|
338
|
+
|
|
339
|
+
Returns
|
|
340
|
+
-------
|
|
341
|
+
chi_squared_red : float
|
|
342
|
+
Reduced chi-squared within GTIs only
|
|
343
|
+
|
|
344
|
+
Raises
|
|
345
|
+
------
|
|
346
|
+
ValueError
|
|
347
|
+
If no overlap between GTIs and time range
|
|
348
|
+
|
|
349
|
+
Examples
|
|
350
|
+
--------
|
|
351
|
+
>>> qm = QualityMetrics(lc, binsize=1.0, emin=20, emax=100)
|
|
352
|
+
>>> chi_gti = qm.gti_chi_squared()
|
|
353
|
+
>>> print(f"GTI-filtered chisq/dof = {chi_gti:.2f}")
|
|
354
|
+
>>>
|
|
355
|
+
>>> # Use custom GTIs
|
|
356
|
+
>>> custom_gtis = np.array([[3000.0, 3100.0], [3200.0, 3300.0]])
|
|
357
|
+
>>> chi_custom = qm.gti_chi_squared(gtis=custom_gtis)
|
|
358
|
+
|
|
359
|
+
Notes
|
|
360
|
+
-----
|
|
361
|
+
GTIs are always in IJD format, regardless of local_time setting.
|
|
362
|
+
Time array must be converted to IJD for comparison.
|
|
363
|
+
"""
|
|
364
|
+
if counts is None or time is None:
|
|
365
|
+
data = self._compute_counts()
|
|
366
|
+
time, counts = data["time"], data["counts"]
|
|
367
|
+
|
|
368
|
+
if gtis is None:
|
|
369
|
+
if self.lightcurve is None:
|
|
370
|
+
raise ValueError("Must provide gtis or set lightcurve")
|
|
371
|
+
gtis = self.lightcurve.gtis
|
|
372
|
+
|
|
373
|
+
# Check for overlap
|
|
374
|
+
if gtis[0, 0] > time[-1] or gtis[-1, 1] < time[0]:
|
|
375
|
+
raise ValueError(
|
|
376
|
+
f"No overlap between GTIs ({gtis[0,0]:.1f}-{gtis[-1,1]:.1f}) "
|
|
377
|
+
f"and time range ({time[0]:.1f}-{time[-1]:.1f}). "
|
|
378
|
+
"Verify time is in IJD format."
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# Create GTI mask
|
|
382
|
+
gti_mask = np.zeros_like(time, dtype=bool)
|
|
383
|
+
for gti_start, gti_stop in gtis:
|
|
384
|
+
gti_mask |= (time >= gti_start) & (time <= gti_stop)
|
|
385
|
+
|
|
386
|
+
# Apply mask (set non-GTI bins to NaN)
|
|
387
|
+
filtered_counts = np.where(gti_mask, counts, np.nan)
|
|
388
|
+
|
|
389
|
+
return self._compute_chi_squared_red(filtered_counts, return_all=return_all)
|