isgri 0.3.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 +306 -99
- 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.3.0.dist-info/METADATA +0 -66
- isgri-0.3.0.dist-info/RECORD +0 -11
- {isgri-0.3.0.dist-info → isgri-0.4.0.dist-info}/WHEEL +0 -0
- {isgri-0.3.0.dist-info → isgri-0.4.0.dist-info}/licenses/LICENSE +0 -0
isgri/utils/lightcurve.py
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ISGRI Light Curve Analysis
|
|
3
|
+
===========================
|
|
4
|
+
|
|
5
|
+
Tools for working with INTEGRAL/ISGRI event data and creating light curves.
|
|
6
|
+
|
|
7
|
+
Classes
|
|
8
|
+
-------
|
|
9
|
+
LightCurve : Main light curve class
|
|
10
|
+
|
|
11
|
+
Examples
|
|
12
|
+
--------
|
|
13
|
+
>>> from isgri.utils import LightCurve
|
|
14
|
+
>>>
|
|
15
|
+
>>> # Load events with PIF weighting
|
|
16
|
+
>>> lc = LightCurve.load_data(
|
|
17
|
+
... events_path="events.fits",
|
|
18
|
+
... pif_path="model.fits",
|
|
19
|
+
... pif_threshold=0.5
|
|
20
|
+
... )
|
|
21
|
+
>>>
|
|
22
|
+
>>> # Create 1-second binned light curve
|
|
23
|
+
>>> time, counts = lc.rebin(binsize=1.0, emin=20, emax=100)
|
|
24
|
+
>>>
|
|
25
|
+
>>> # Analyze by detector module
|
|
26
|
+
>>> times, module_lcs = lc.rebin_by_modules(1.0, 20, 100)
|
|
27
|
+
"""
|
|
28
|
+
|
|
1
29
|
from astropy.io import fits
|
|
2
30
|
import numpy as np
|
|
31
|
+
from numpy.typing import NDArray
|
|
32
|
+
from typing import Optional, Union, Tuple, List
|
|
33
|
+
from pathlib import Path
|
|
3
34
|
import os
|
|
4
35
|
from .file_loaders import load_isgri_events, load_isgri_pif, default_pif_metadata, merge_metadata
|
|
5
36
|
from .pif import DETZ_BOUNDS, DETY_BOUNDS
|
|
@@ -7,54 +38,112 @@ from .pif import DETZ_BOUNDS, DETY_BOUNDS
|
|
|
7
38
|
|
|
8
39
|
class LightCurve:
|
|
9
40
|
"""
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
41
|
+
ISGRI light curve analysis class.
|
|
42
|
+
|
|
43
|
+
Handles event data with optional detector response (PIF) weighting.
|
|
44
|
+
Provides rebinning, module-level analysis, and time conversions.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
time : ndarray
|
|
49
|
+
IJD time values of events
|
|
50
|
+
energies : ndarray
|
|
51
|
+
Energy values in keV
|
|
52
|
+
gtis : ndarray
|
|
53
|
+
Good Time Intervals (start, stop) in IJD
|
|
54
|
+
dety : ndarray
|
|
55
|
+
Y detector coordinates (mm)
|
|
56
|
+
detz : ndarray
|
|
57
|
+
Z detector coordinates (mm)
|
|
58
|
+
weights : ndarray
|
|
59
|
+
PIF weight for each event (1.0 if no PIF)
|
|
60
|
+
metadata : dict
|
|
61
|
+
Event metadata (SWID, source info, etc.)
|
|
62
|
+
|
|
63
|
+
Attributes
|
|
64
|
+
----------
|
|
65
|
+
t0 : float
|
|
66
|
+
Reference time (first event time in IJD)
|
|
67
|
+
local_time : ndarray
|
|
68
|
+
Time relative to t0 in seconds
|
|
69
|
+
|
|
70
|
+
Examples
|
|
71
|
+
--------
|
|
72
|
+
>>> lc = LightCurve.load_data("events.fits", pif_path="model.fits")
|
|
73
|
+
>>> time, counts = lc.rebin(binsize=1.0, emin=20, emax=100)
|
|
74
|
+
>>> print(f"Total counts: {counts.sum()}")
|
|
75
|
+
|
|
76
|
+
>>> # Module-by-module analysis
|
|
77
|
+
>>> times, module_counts = lc.rebin_by_modules(1.0, 20, 100)
|
|
78
|
+
>>> print(f"Module 3 counts: {module_counts[3].sum()}")
|
|
79
|
+
|
|
80
|
+
See Also
|
|
81
|
+
--------
|
|
82
|
+
load_data : Load events from FITS files
|
|
83
|
+
rebin : Rebin light curve
|
|
84
|
+
rebin_by_modules : Rebin by detector module
|
|
30
85
|
"""
|
|
31
86
|
|
|
32
|
-
|
|
87
|
+
time: NDArray[np.float64]
|
|
88
|
+
energies: NDArray[np.float64]
|
|
89
|
+
gtis: NDArray[np.float64]
|
|
90
|
+
t0: float
|
|
91
|
+
local_time: NDArray[np.float64]
|
|
92
|
+
dety: NDArray[np.float64]
|
|
93
|
+
detz: NDArray[np.float64]
|
|
94
|
+
weights: NDArray[np.float64]
|
|
95
|
+
metadata: dict
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
time: NDArray[np.float64],
|
|
100
|
+
energies: NDArray[np.float64],
|
|
101
|
+
gtis: NDArray[np.float64],
|
|
102
|
+
dety: NDArray[np.float64],
|
|
103
|
+
detz: NDArray[np.float64],
|
|
104
|
+
weights: NDArray[np.float64],
|
|
105
|
+
metadata: dict,
|
|
106
|
+
) -> None:
|
|
33
107
|
"""
|
|
34
108
|
Initialize LightCurve instance.
|
|
35
109
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
110
|
+
Parameters
|
|
111
|
+
----------
|
|
112
|
+
time : ndarray
|
|
113
|
+
IJD time values
|
|
114
|
+
energies : ndarray
|
|
115
|
+
Energy values in keV
|
|
116
|
+
gtis : ndarray
|
|
117
|
+
Good Time Intervals
|
|
118
|
+
dety : ndarray
|
|
119
|
+
Y detector coordinates
|
|
120
|
+
detz : ndarray
|
|
121
|
+
Z detector coordinates
|
|
122
|
+
weights : ndarray
|
|
123
|
+
PIF weights for each event
|
|
124
|
+
metadata : dict
|
|
125
|
+
Event metadata
|
|
44
126
|
"""
|
|
45
127
|
self.time = time
|
|
46
128
|
self.energies = energies
|
|
47
129
|
self.gtis = gtis
|
|
48
130
|
self.t0 = time[0]
|
|
49
131
|
self.local_time = (time - self.t0) * 86400
|
|
50
|
-
|
|
51
132
|
self.dety = dety
|
|
52
133
|
self.detz = detz
|
|
53
134
|
self.weights = weights
|
|
54
135
|
self.metadata = metadata
|
|
55
136
|
|
|
56
137
|
@classmethod
|
|
57
|
-
def load_data(
|
|
138
|
+
def load_data(
|
|
139
|
+
cls,
|
|
140
|
+
events_path: Optional[Union[str, Path]] = None,
|
|
141
|
+
pif_path: Optional[Union[str, Path]] = None,
|
|
142
|
+
scw: Optional[str] = None,
|
|
143
|
+
source: Optional[str] = None,
|
|
144
|
+
pif_threshold: float = 0.5,
|
|
145
|
+
pif_extension: int = -1,
|
|
146
|
+
) -> "LightCurve":
|
|
58
147
|
"""
|
|
59
148
|
Loads the events from the given events file and PIF file (optional).
|
|
60
149
|
|
|
@@ -71,6 +160,9 @@ class LightCurve:
|
|
|
71
160
|
"""
|
|
72
161
|
events, gtis, metadata = load_isgri_events(events_path)
|
|
73
162
|
if pif_path:
|
|
163
|
+
if pif_threshold < 0 or pif_threshold > 1:
|
|
164
|
+
raise ValueError(f"pif_threshold must be in [0, 1], got {pif_threshold}")
|
|
165
|
+
|
|
74
166
|
events, weights, metadata_pif = load_isgri_pif(pif_path, events, pif_threshold, pif_extension)
|
|
75
167
|
else:
|
|
76
168
|
weights = np.ones(len(events))
|
|
@@ -82,7 +174,14 @@ class LightCurve:
|
|
|
82
174
|
dety, detz = events["DETY"], events["DETZ"]
|
|
83
175
|
return cls(time, energies, gtis, dety, detz, weights, metadata)
|
|
84
176
|
|
|
85
|
-
def rebin(
|
|
177
|
+
def rebin(
|
|
178
|
+
self,
|
|
179
|
+
binsize: Union[float, NDArray[np.float64], List[float]],
|
|
180
|
+
emin: float,
|
|
181
|
+
emax: float,
|
|
182
|
+
local_time: bool = True,
|
|
183
|
+
custom_mask: Optional[NDArray[np.bool_]] = None,
|
|
184
|
+
) -> Tuple[NDArray[np.float64], NDArray[np.float64]]:
|
|
86
185
|
"""
|
|
87
186
|
Rebins the events with the specified bin size and energy range.
|
|
88
187
|
|
|
@@ -103,8 +202,20 @@ class LightCurve:
|
|
|
103
202
|
>>> time, counts = lc.rebin(binsize=1.0, emin=30, emax=300)
|
|
104
203
|
>>> time, counts = lc.rebin(binsize=[0, 1, 2, 5, 10], emin=50, emax=200)
|
|
105
204
|
"""
|
|
205
|
+
# Validate inputs
|
|
106
206
|
if emin >= emax:
|
|
107
|
-
raise ValueError("emin must be less than emax")
|
|
207
|
+
raise ValueError(f"emin ({emin}) must be less than emax ({emax})")
|
|
208
|
+
|
|
209
|
+
if emin < 0:
|
|
210
|
+
raise ValueError(f"emin must be non-negative, got {emin}")
|
|
211
|
+
|
|
212
|
+
if isinstance(binsize, (int, float)) and binsize <= 0:
|
|
213
|
+
raise ValueError(f"binsize must be positive, got {binsize}")
|
|
214
|
+
|
|
215
|
+
if custom_mask is not None and len(custom_mask) != len(self.time):
|
|
216
|
+
raise ValueError(
|
|
217
|
+
f"custom_mask length ({len(custom_mask)}) must match " f"number of events ({len(self.time)})"
|
|
218
|
+
)
|
|
108
219
|
|
|
109
220
|
# Select time axis
|
|
110
221
|
time = self.local_time if local_time else self.time
|
|
@@ -124,7 +235,13 @@ class LightCurve:
|
|
|
124
235
|
|
|
125
236
|
return time_centers, counts
|
|
126
237
|
|
|
127
|
-
def _create_bins(
|
|
238
|
+
def _create_bins(
|
|
239
|
+
self,
|
|
240
|
+
binsize: Union[float, NDArray[np.float64], List[float]],
|
|
241
|
+
time: NDArray[np.float64],
|
|
242
|
+
t0: float,
|
|
243
|
+
local_time: bool,
|
|
244
|
+
) -> Tuple[NDArray[np.float64], float]:
|
|
128
245
|
"""
|
|
129
246
|
Create time bins for rebinning.
|
|
130
247
|
|
|
@@ -148,7 +265,12 @@ class LightCurve:
|
|
|
148
265
|
|
|
149
266
|
return bins, binsize_actual
|
|
150
267
|
|
|
151
|
-
def _create_event_mask(
|
|
268
|
+
def _create_event_mask(
|
|
269
|
+
self,
|
|
270
|
+
emin: float,
|
|
271
|
+
emax: float,
|
|
272
|
+
custom_mask: Optional[NDArray[np.bool_]] = None,
|
|
273
|
+
) -> NDArray[np.bool_]:
|
|
152
274
|
"""
|
|
153
275
|
Create combined event filter mask.
|
|
154
276
|
|
|
@@ -169,7 +291,14 @@ class LightCurve:
|
|
|
169
291
|
|
|
170
292
|
return mask
|
|
171
293
|
|
|
172
|
-
def rebin_by_modules(
|
|
294
|
+
def rebin_by_modules(
|
|
295
|
+
self,
|
|
296
|
+
binsize: float,
|
|
297
|
+
emin: float,
|
|
298
|
+
emax: float,
|
|
299
|
+
local_time: bool = True,
|
|
300
|
+
custom_mask: Optional[NDArray[np.bool_]] = None,
|
|
301
|
+
) -> Tuple[NDArray[np.float64], List[NDArray[np.float64]]]:
|
|
173
302
|
"""
|
|
174
303
|
Rebins the events by all 8 detector modules with the specified bin size and energy range.
|
|
175
304
|
|
|
@@ -222,7 +351,14 @@ class LightCurve:
|
|
|
222
351
|
|
|
223
352
|
return times, counts
|
|
224
353
|
|
|
225
|
-
def cts(
|
|
354
|
+
def cts(
|
|
355
|
+
self,
|
|
356
|
+
t1: float,
|
|
357
|
+
t2: float,
|
|
358
|
+
emin: float,
|
|
359
|
+
emax: float,
|
|
360
|
+
local_time: bool = True,
|
|
361
|
+
) -> float:
|
|
226
362
|
"""
|
|
227
363
|
Calculates the counts in the specified time and energy range.
|
|
228
364
|
|
|
@@ -232,7 +368,6 @@ class LightCurve:
|
|
|
232
368
|
emin (float): The minimum energy value in keV.
|
|
233
369
|
emax (float): The maximum energy value in keV.
|
|
234
370
|
local_time (bool, optional): If True, uses local time. Defaults to True.
|
|
235
|
-
bkg (bool, optional): Reserved for future background subtraction. Defaults to False.
|
|
236
371
|
|
|
237
372
|
Returns:
|
|
238
373
|
float: The total counts in the specified range.
|
|
@@ -240,7 +375,7 @@ class LightCurve:
|
|
|
240
375
|
time = self.local_time if local_time else self.time
|
|
241
376
|
return np.sum(self.weights[(time >= t1) & (time < t2) & (self.energies >= emin) & (self.energies < emax)])
|
|
242
377
|
|
|
243
|
-
def ijd2loc(self, ijd_time):
|
|
378
|
+
def ijd2loc(self, ijd_time: Union[float, NDArray[np.float64]]) -> Union[float, NDArray[np.float64]]:
|
|
244
379
|
"""
|
|
245
380
|
Converts IJD (INTEGRAL Julian Date) time to local time.
|
|
246
381
|
|
|
@@ -252,7 +387,7 @@ class LightCurve:
|
|
|
252
387
|
"""
|
|
253
388
|
return (ijd_time - self.t0) * 86400
|
|
254
389
|
|
|
255
|
-
def loc2ijd(self, evt_time):
|
|
390
|
+
def loc2ijd(self, evt_time: Union[float, NDArray[np.float64]]) -> Union[float, NDArray[np.float64]]:
|
|
256
391
|
"""
|
|
257
392
|
Converts local time to IJD (INTEGRAL Julian Date) time.
|
|
258
393
|
|
|
@@ -263,3 +398,12 @@ class LightCurve:
|
|
|
263
398
|
float or ndarray: The IJD time value(s).
|
|
264
399
|
"""
|
|
265
400
|
return evt_time / 86400 + self.t0
|
|
401
|
+
|
|
402
|
+
def __repr__(self) -> str:
|
|
403
|
+
"""String representation."""
|
|
404
|
+
return (
|
|
405
|
+
f"LightCurve(n_events={len(self.time)}, "
|
|
406
|
+
f"time_range=({self.time[0]:.3f}, {self.time[-1]:.3f}) IJD, "
|
|
407
|
+
f"energy_range=({self.energies.min():.1f}, {self.energies.max():.1f}) keV, "
|
|
408
|
+
f"scw={self.metadata.get('SWID', 'Unknown')})"
|
|
409
|
+
)
|
isgri/utils/pif.py
CHANGED
|
@@ -1,41 +1,286 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ISGRI Detector Pixel Illumination Fraction (PIF) Tools
|
|
3
|
+
========================================================
|
|
4
|
+
|
|
5
|
+
Functions for working with ISGRI detector response maps (PIF files).
|
|
6
|
+
PIF values indicate what fraction of source flux reaches each detector pixel,
|
|
7
|
+
accounting for shadowing by the coded mask.
|
|
8
|
+
|
|
9
|
+
PIF values range from 0 (fully shadowed) to 1 (fully illuminated).
|
|
10
|
+
|
|
11
|
+
Functions
|
|
12
|
+
---------
|
|
13
|
+
select_isgri_module : Get detector coordinate bounds for a module
|
|
14
|
+
apply_pif_mask : Filter events by PIF threshold
|
|
15
|
+
coding_fraction : Calculate coded fraction for source position
|
|
16
|
+
estimate_active_modules : Determine which modules have significant PIF coverage
|
|
17
|
+
|
|
18
|
+
Examples
|
|
19
|
+
--------
|
|
20
|
+
>>> import numpy as np
|
|
21
|
+
>>> from astropy.table import Table
|
|
22
|
+
>>>
|
|
23
|
+
>>> # Load PIF and events
|
|
24
|
+
>>> pif_file = np.random.rand(134, 130) # Mock PIF
|
|
25
|
+
>>> events = Table({'DETZ': [10, 20], 'DETY': [15, 25]})
|
|
26
|
+
>>>
|
|
27
|
+
>>> # Apply PIF weighting
|
|
28
|
+
>>> filtered_events, weights = apply_pif_mask(pif_file, events, pif_threshold=0.5)
|
|
29
|
+
>>>
|
|
30
|
+
>>> # Check which modules are active
|
|
31
|
+
>>> active = estimate_active_modules(pif_file)
|
|
32
|
+
>>> print(f"Active modules: {np.where(active)[0]}")
|
|
33
|
+
"""
|
|
34
|
+
|
|
1
35
|
import numpy as np
|
|
36
|
+
from numpy.typing import NDArray
|
|
37
|
+
from typing import Tuple
|
|
38
|
+
from astropy.table import Table
|
|
39
|
+
|
|
40
|
+
# ISGRI detector module boundaries (mm coordinates)
|
|
41
|
+
# 8 modules total: 4 rows × 2 columns
|
|
42
|
+
# Z-axis (rows): 4 modules
|
|
43
|
+
# Y-axis (cols): 2 modules
|
|
44
|
+
DETZ_BOUNDS = [0, 32, 66, 100, 134] # 5 boundaries for 4 rows
|
|
45
|
+
DETY_BOUNDS = [0, 64, 130] # 3 boundaries for 2 columns
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def select_isgri_module(module_no: int) -> Tuple[int, int, int, int]:
|
|
49
|
+
"""
|
|
50
|
+
Get detector coordinate bounds for specified module.
|
|
51
|
+
|
|
52
|
+
ISGRI has 8 modules arranged in 4 rows × 2 columns:
|
|
53
|
+
|
|
54
|
+
Module layout:
|
|
55
|
+
[0] [1]
|
|
56
|
+
[2] [3]
|
|
57
|
+
[4] [5]
|
|
58
|
+
[6] [7]
|
|
59
|
+
|
|
60
|
+
Parameters
|
|
61
|
+
----------
|
|
62
|
+
module_no : int
|
|
63
|
+
Module number (0-7)
|
|
64
|
+
|
|
65
|
+
Returns
|
|
66
|
+
-------
|
|
67
|
+
z1, z2, y1, y2 : int
|
|
68
|
+
Detector coordinate bounds (DETZ min/max, DETY min/max)
|
|
69
|
+
|
|
70
|
+
Raises
|
|
71
|
+
------
|
|
72
|
+
ValueError
|
|
73
|
+
If module_no not in range [0, 7]
|
|
2
74
|
|
|
3
|
-
|
|
75
|
+
Examples
|
|
76
|
+
--------
|
|
77
|
+
>>> z1, z2, y1, y2 = select_isgri_module(0)
|
|
78
|
+
>>> print(f"Module 0: DETZ=[{z1},{z2}], DETY=[{y1},{y2}]")
|
|
79
|
+
Module 0: DETZ=[0,32], DETY=[0,64]
|
|
4
80
|
|
|
81
|
+
>>> # Module 3 is bottom-right
|
|
82
|
+
>>> select_isgri_module(3)
|
|
83
|
+
(66, 100, 64, 130)
|
|
84
|
+
"""
|
|
85
|
+
if not (0 <= module_no <= 7):
|
|
86
|
+
raise ValueError(f"module_no must be in [0, 7], got {module_no}")
|
|
5
87
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
88
|
+
col = module_no % 2 # 0=left, 1=right
|
|
89
|
+
row = module_no // 2 # 0-3 from top to bottom
|
|
90
|
+
|
|
91
|
+
z1, z2 = DETZ_BOUNDS[row], DETZ_BOUNDS[row + 1]
|
|
10
92
|
y1, y2 = DETY_BOUNDS[col], DETY_BOUNDS[col + 1]
|
|
11
|
-
return x1, x2, y1, y2
|
|
12
93
|
|
|
94
|
+
return z1, z2, y1, y2
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def apply_pif_mask(
|
|
98
|
+
pif_file: NDArray[np.float64],
|
|
99
|
+
events: Table,
|
|
100
|
+
pif_threshold: float = 0.5,
|
|
101
|
+
) -> Tuple[Table, NDArray[np.float64]]:
|
|
102
|
+
"""
|
|
103
|
+
Filter events by PIF threshold and return PIF weights.
|
|
104
|
+
|
|
105
|
+
Events with PIF < threshold are removed. Remaining events are
|
|
106
|
+
weighted by their PIF values for response correction.
|
|
107
|
+
|
|
108
|
+
Parameters
|
|
109
|
+
----------
|
|
110
|
+
pif_file : ndarray, shape (134, 130)
|
|
111
|
+
2D PIF array (DETZ x DETY coordinates)
|
|
112
|
+
events : Table
|
|
113
|
+
Event table with 'DETZ' and 'DETY' columns
|
|
114
|
+
pif_threshold : float, default 0.5
|
|
115
|
+
Minimum PIF value to keep event (0.0-1.0)
|
|
116
|
+
|
|
117
|
+
Returns
|
|
118
|
+
-------
|
|
119
|
+
filtered_events : Table
|
|
120
|
+
Events with PIF >= threshold
|
|
121
|
+
pif_weights : ndarray
|
|
122
|
+
PIF value for each filtered event
|
|
123
|
+
|
|
124
|
+
Raises
|
|
125
|
+
------
|
|
126
|
+
ValueError
|
|
127
|
+
If pif_threshold not in [0, 1]
|
|
128
|
+
If events missing 'DETZ' or 'DETY' columns
|
|
129
|
+
If PIF dimensions don't match expected (134, 130)
|
|
13
130
|
|
|
14
|
-
|
|
131
|
+
Examples
|
|
132
|
+
--------
|
|
133
|
+
>>> pif = np.random.rand(134, 130)
|
|
134
|
+
>>> events = Table({'DETZ': [10, 20, 30], 'DETY': [15, 25, 35]})
|
|
135
|
+
>>>
|
|
136
|
+
>>> # Keep only well-illuminated events
|
|
137
|
+
>>> filtered, weights = apply_pif_mask(pif, events, pif_threshold=0.7)
|
|
138
|
+
>>> print(f"Kept {len(filtered)}/{len(events)} events")
|
|
139
|
+
>>> print(f"Mean weight: {weights.mean():.3f}")
|
|
140
|
+
"""
|
|
141
|
+
# Validate inputs
|
|
142
|
+
if not (0 <= pif_threshold <= 1):
|
|
143
|
+
raise ValueError(f"pif_threshold must be in [0, 1], got {pif_threshold}")
|
|
144
|
+
|
|
145
|
+
if pif_file.shape != (134, 130):
|
|
146
|
+
raise ValueError(f"PIF file must have shape (134, 130), got {pif_file.shape}")
|
|
147
|
+
|
|
148
|
+
if "DETZ" not in events.colnames or "DETY" not in events.colnames:
|
|
149
|
+
raise ValueError("Events table must have 'DETZ' and 'DETY' columns")
|
|
150
|
+
|
|
151
|
+
# Create mask for events above threshold
|
|
15
152
|
pif_filter = pif_file > pif_threshold
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
153
|
+
|
|
154
|
+
# Get PIF values at event positions
|
|
155
|
+
event_pif = pif_file[events["DETZ"], events["DETY"]]
|
|
156
|
+
|
|
157
|
+
# Apply filter
|
|
158
|
+
mask = event_pif > pif_threshold
|
|
159
|
+
filtered_events = events[mask]
|
|
160
|
+
pif_weights = event_pif[mask]
|
|
161
|
+
|
|
162
|
+
return filtered_events, pif_weights
|
|
19
163
|
|
|
20
164
|
|
|
21
|
-
def coding_fraction(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
return pif_cod
|
|
165
|
+
def coding_fraction(
|
|
166
|
+
pif_file: NDArray[np.float64],
|
|
167
|
+
events: Table,
|
|
168
|
+
) -> float:
|
|
169
|
+
"""
|
|
170
|
+
Calculate fraction of detector that is fully coded.
|
|
28
171
|
|
|
172
|
+
Uses events with PIF=1.0 (fully illuminated) to estimate
|
|
173
|
+
the size of the fully coded field of view.
|
|
174
|
+
|
|
175
|
+
Parameters
|
|
176
|
+
----------
|
|
177
|
+
pif_file : ndarray, shape (134, 130)
|
|
178
|
+
2D PIF array
|
|
179
|
+
events : Table
|
|
180
|
+
Event table with 'DETZ' and 'DETY' columns
|
|
181
|
+
|
|
182
|
+
Returns
|
|
183
|
+
-------
|
|
184
|
+
coding_fraction : float
|
|
185
|
+
Fraction of detector area that is fully coded (0.0-1.0)
|
|
186
|
+
|
|
187
|
+
Notes
|
|
188
|
+
-----
|
|
189
|
+
Fully coded region has PIF=1.0 for on-axis sources.
|
|
190
|
+
Partially coded region has 0 < PIF < 1.
|
|
191
|
+
|
|
192
|
+
Examples
|
|
193
|
+
--------
|
|
194
|
+
>>> pif = np.ones((134, 130))
|
|
195
|
+
>>> pif[50:80, 40:90] = 1.0 # Fully coded region
|
|
196
|
+
>>> events = Table({'DETZ': np.arange(134), 'DETY': np.arange(130)})
|
|
197
|
+
>>>
|
|
198
|
+
>>> frac = coding_fraction(pif, events)
|
|
199
|
+
>>> print(f"Coding fraction: {frac:.2%}")
|
|
200
|
+
"""
|
|
201
|
+
if pif_file.shape != (134, 130):
|
|
202
|
+
raise ValueError(f"PIF must have shape (134, 130), got {pif_file.shape}")
|
|
203
|
+
|
|
204
|
+
if "DETZ" not in events.colnames or "DETY" not in events.colnames:
|
|
205
|
+
raise ValueError("Events must have 'DETZ' and 'DETY' columns")
|
|
206
|
+
|
|
207
|
+
# Find fully coded pixels (PIF = 1.0)
|
|
208
|
+
fully_coded = pif_file == 1.0
|
|
209
|
+
|
|
210
|
+
# Get events in fully coded region
|
|
211
|
+
coded_events = events[fully_coded[events["DETZ"], events["DETY"]]]
|
|
212
|
+
|
|
213
|
+
if len(coded_events) == 0:
|
|
214
|
+
return 0.0
|
|
215
|
+
|
|
216
|
+
# Calculate extent in Y and Z
|
|
217
|
+
dety_range = np.max(coded_events["DETY"]) - np.min(coded_events["DETY"])
|
|
218
|
+
detz_range = np.max(coded_events["DETZ"]) - np.min(coded_events["DETZ"])
|
|
219
|
+
|
|
220
|
+
# Normalize by detector size (Y: 0-129, Z: 0-133)
|
|
221
|
+
frac_y = dety_range / 129.0
|
|
222
|
+
frac_z = detz_range / 133.0
|
|
223
|
+
|
|
224
|
+
# Area fraction
|
|
225
|
+
coding_frac = frac_y * frac_z
|
|
226
|
+
|
|
227
|
+
return coding_frac
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def estimate_active_modules(
|
|
231
|
+
pif_file: NDArray[np.float64],
|
|
232
|
+
threshold: float = 0.2,
|
|
233
|
+
) -> NDArray[np.bool_]:
|
|
234
|
+
"""
|
|
235
|
+
Determine which detector modules have significant PIF coverage.
|
|
236
|
+
|
|
237
|
+
A module is considered active if more than `threshold` fraction
|
|
238
|
+
of its pixels have PIF > 0.01.
|
|
239
|
+
|
|
240
|
+
Parameters
|
|
241
|
+
----------
|
|
242
|
+
pif_file : ndarray, shape (134, 130)
|
|
243
|
+
2D PIF array
|
|
244
|
+
threshold : float, default 0.2
|
|
245
|
+
Minimum fraction of illuminated pixels (0.0-1.0)
|
|
246
|
+
|
|
247
|
+
Returns
|
|
248
|
+
-------
|
|
249
|
+
active_modules : ndarray of bool, shape (8,)
|
|
250
|
+
True if module is active, False otherwise
|
|
251
|
+
|
|
252
|
+
Examples
|
|
253
|
+
--------
|
|
254
|
+
>>> pif = np.random.rand(134, 130)
|
|
255
|
+
>>> pif[:50, :] = 0 # Top modules dark
|
|
256
|
+
>>>
|
|
257
|
+
>>> active = estimate_active_modules(pif, threshold=0.2)
|
|
258
|
+
>>> print(f"Active modules: {np.where(active)[0]}")
|
|
259
|
+
Active modules: [2 3 4 5 6 7]
|
|
260
|
+
|
|
261
|
+
>>> # Get list of active module numbers
|
|
262
|
+
>>> active_list = np.where(active)[0].tolist()
|
|
263
|
+
"""
|
|
264
|
+
if pif_file.shape != (134, 130):
|
|
265
|
+
raise ValueError(f"PIF must have shape (134, 130), got {pif_file.shape}")
|
|
266
|
+
|
|
267
|
+
if not (0 <= threshold <= 1):
|
|
268
|
+
raise ValueError(f"threshold must be in [0, 1], got {threshold}")
|
|
269
|
+
|
|
270
|
+
active_modules = np.zeros(8, dtype=bool)
|
|
29
271
|
|
|
30
|
-
def estimate_active_modules(mask):
|
|
31
|
-
m, n = DETZ_BOUNDS, DETY_BOUNDS # Separate modules
|
|
32
|
-
mods = []
|
|
33
272
|
for module_no in range(8):
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
273
|
+
z1, z2, y1, y2 = select_isgri_module(module_no)
|
|
274
|
+
|
|
275
|
+
# Get PIF values for this module
|
|
276
|
+
module_pif = pif_file[z1:z2, y1:y2].flatten()
|
|
277
|
+
|
|
278
|
+
# Count illuminated pixels (PIF > 0.01)
|
|
279
|
+
n_illuminated = np.sum(module_pif > 0.01)
|
|
280
|
+
n_total = len(module_pif)
|
|
281
|
+
|
|
282
|
+
# Check if fraction exceeds threshold
|
|
283
|
+
if n_illuminated / n_total > threshold:
|
|
284
|
+
active_modules[module_no] = True
|
|
285
|
+
|
|
286
|
+
return active_modules
|