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/utils/pif.py CHANGED
@@ -1,41 +1,286 @@
1
- import numpy as np
2
-
3
- DETZ_BOUNDS, DETY_BOUNDS = [0, 32, 66, 100, 134], [0, 64, 130] # Detector module boundaries
4
-
5
-
6
- def select_isgri_module(module_no):
7
- col = 0 if module_no % 2 == 0 else 1
8
- row = module_no // 2
9
- x1, x2 = DETZ_BOUNDS[row], DETZ_BOUNDS[row + 1]
10
- y1, y2 = DETY_BOUNDS[col], DETY_BOUNDS[col + 1]
11
- return x1, x2, y1, y2
12
-
13
-
14
- def apply_pif_mask(pif_file, events, pif_threshold=0.5):
15
- pif_filter = pif_file > pif_threshold
16
- piffed_events = events[pif_filter[events["DETZ"], events["DETY"]]]
17
- pif = pif_file[piffed_events["DETZ"], piffed_events["DETY"]]
18
- return piffed_events, pif
19
-
20
-
21
- def coding_fraction(pif_file, events):
22
- pif_cod = pif_file == 1
23
- pif_cod = events[pif_cod[events["DETZ"], events["DETY"]]]
24
- cody = (np.max(pif_cod["DETY"]) - np.min(pif_cod["DETY"])) / 129
25
- codz = (np.max(pif_cod["DETZ"]) - np.min(pif_cod["DETZ"])) / 133
26
- pif_cod = codz * cody
27
- return pif_cod
28
-
29
-
30
- def estimate_active_modules(mask):
31
- m, n = DETZ_BOUNDS, DETY_BOUNDS # Separate modules
32
- mods = []
33
- for module_no in range(8):
34
- x1, x2, y1, y2 = select_isgri_module(module_no)
35
- a = mask[x1:x2, y1:y2].flatten()
36
- if len(a[a > 0.01]) / len(a) > 0.2:
37
- mods.append(1)
38
- else:
39
- mods.append(0)
40
- mods = np.array(mods)
41
- return mods
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
+
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]
74
+
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]
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}")
87
+
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]
92
+ y1, y2 = DETY_BOUNDS[col], DETY_BOUNDS[col + 1]
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)
130
+
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
152
+ pif_filter = pif_file > pif_threshold
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
163
+
164
+
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.
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)
271
+
272
+ for module_no in range(8):
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