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.
@@ -1,389 +1,392 @@
1
- """
2
- ISGRI Data File Loaders
3
- ========================
4
-
5
- Load INTEGRAL/ISGRI event files and detector response (PIF) files.
6
-
7
- Functions
8
- ---------
9
- load_isgri_events : Load photon events from FITS file
10
- load_isgri_pif : Load and apply detector response (PIF) file
11
- verify_events_path : Validate and resolve events file path
12
- default_pif_metadata : Create default PIF metadata
13
- merge_metadata : Combine events and PIF metadata
14
-
15
- Examples
16
- --------
17
- >>> from isgri.utils import load_isgri_events, load_isgri_pif
18
- >>>
19
- >>> # Load events
20
- >>> events, gtis, metadata = load_isgri_events("isgri_events.fits")
21
- >>> print(f"Loaded {len(events)} events")
22
- >>>
23
- >>> # Apply PIF weighting
24
- >>> filtered_events, pif_weights, pif_meta = load_isgri_pif(
25
- ... "pif_model.fits",
26
- ... events,
27
- ... pif_threshold=0.5
28
- ... )
29
- >>> print(f"Kept {len(filtered_events)}/{len(events)} events")
30
- >>> print(f"Coding fraction: {pif_meta['cod']:.2%}")
31
-
32
- Notes
33
- -----
34
- - Events files contain photon arrival times, energies, and detector positions
35
- - PIF files contain detector response maps for specific source positions
36
- - PIF weighting corrects for shadowing by the coded mask
37
- """
38
-
39
- from astropy.io import fits
40
- from astropy.table import Table
41
- import numpy as np
42
- from numpy.typing import NDArray
43
- from typing import Tuple, Dict, Optional, Union
44
- from pathlib import Path
45
- import os
46
- from .pif import apply_pif_mask, coding_fraction, estimate_active_modules
47
-
48
-
49
- def verify_events_path(path: Union[str, Path]) -> str:
50
- """
51
- Verify and resolve the events file path.
52
-
53
- Parameters
54
- ----------
55
- path : str or Path
56
- File path or directory path containing events file.
57
- If directory, searches for files containing 'isgri_events'.
58
-
59
- Returns
60
- -------
61
- resolved_path : str
62
- Resolved absolute path to valid events file.
63
-
64
- Raises
65
- ------
66
- FileNotFoundError
67
- If path doesn't exist, no events file found, or multiple
68
- candidates found in directory.
69
- ValueError
70
- If ISGR-EVTS-ALL extension not found in file.
71
-
72
- Examples
73
- --------
74
- >>> # Direct file path
75
- >>> path = verify_events_path("isgri_events.fits")
76
-
77
- >>> # Directory with single events file
78
- >>> path = verify_events_path("/data/scw/0234/001200340010.001")
79
-
80
- >>> # Will raise error if multiple files
81
- >>> verify_events_path("/data/scw/") # Multiple SCWs present
82
- FileNotFoundError: Multiple isgri_events files found...
83
- """
84
- path = Path(path)
85
-
86
- if path.is_file():
87
- resolved_path = str(path)
88
- elif path.is_dir():
89
- candidate_files = [f for f in os.listdir(path) if "isgri_events" in f]
90
-
91
- if len(candidate_files) == 0:
92
- raise FileNotFoundError(f"No isgri_events file found in directory: {path}")
93
- elif len(candidate_files) > 1:
94
- raise FileNotFoundError(
95
- f"Multiple isgri_events files found in directory: {path}\n"
96
- f"Found: {candidate_files}\n"
97
- "Please specify the exact file path."
98
- )
99
- else:
100
- resolved_path = str(path / candidate_files[0])
101
- else:
102
- raise FileNotFoundError(f"Path does not exist: {path}")
103
-
104
- # Verify FITS structure
105
- try:
106
- with fits.open(resolved_path) as hdu:
107
- if "ISGR-EVTS-ALL" not in hdu:
108
- raise ValueError(f"Invalid events file: ISGR-EVTS-ALL extension not found in {resolved_path}")
109
- except Exception as e:
110
- if isinstance(e, (FileNotFoundError, ValueError)):
111
- raise
112
- raise ValueError(f"Cannot open FITS file {resolved_path}: {e}")
113
-
114
- return resolved_path
115
-
116
-
117
- def load_isgri_events(events_path: Union[str, Path]) -> Tuple[Table, NDArray[np.float64], Dict]:
118
- """
119
- Load ISGRI photon events from FITS file.
120
-
121
- Parameters
122
- ----------
123
- events_path : str or Path
124
- Path to events FITS file or directory containing it.
125
-
126
- Returns
127
- -------
128
- events : Table
129
- Astropy Table with columns:
130
- - TIME : Event time in IJD
131
- - ISGRI_ENERGY : Energy in keV
132
- - DETY : Y detector coordinate (0-129)
133
- - DETZ : Z detector coordinate (0-133)
134
- gtis : ndarray, shape (N, 2)
135
- Good Time Intervals [start, stop] pairs in IJD.
136
- If no GTI extension found, uses [first_event, last_event].
137
- metadata : dict
138
- Header metadata with keys:
139
- - REVOL : Revolution number
140
- - SWID : Science Window ID
141
- - TSTART, TSTOP : Start/stop times (IJD)
142
- - RA_SCX, DEC_SCX : Pointing axis coordinates
143
- - RA_SCZ, DEC_SCZ : Z-axis coordinates
144
-
145
- Raises
146
- ------
147
- FileNotFoundError
148
- If events file not found or invalid.
149
- ValueError
150
- If required FITS extension missing.
151
-
152
- Examples
153
- --------
154
- >>> events, gtis, meta = load_isgri_events("isgri_events.fits")
155
- >>> print(f"Loaded {len(events)} events")
156
- >>> print(f"Time range: {meta['TSTART']:.1f} - {meta['TSTOP']:.1f} IJD")
157
- >>> print(f"GTIs: {len(gtis)} intervals")
158
-
159
- >>> # Check energy range
160
- >>> print(f"Energy: {events['ISGRI_ENERGY'].min():.1f} - "
161
- ... f"{events['ISGRI_ENERGY'].max():.1f} keV")
162
-
163
- See Also
164
- --------
165
- load_isgri_pif : Apply detector response weighting
166
- """
167
- confirmed_path = verify_events_path(events_path)
168
-
169
- with fits.open(confirmed_path) as hdu:
170
- # Load events
171
- events_data = hdu["ISGR-EVTS-ALL"].data
172
- header = hdu["ISGR-EVTS-ALL"].header
173
-
174
- # Extract metadata
175
- metadata = {
176
- "REVOL": header.get("REVOL"),
177
- "SWID": header.get("SWID"),
178
- "TSTART": header.get("TSTART"),
179
- "TSTOP": header.get("TSTOP"),
180
- "TELAPSE": header.get("TELAPSE"),
181
- "OBT_TSTART": header.get("OBTSTART"),
182
- "OBT_TSTOP": header.get("OBTEND"),
183
- "RA_SCX": header.get("RA_SCX"),
184
- "DEC_SCX": header.get("DEC_SCX"),
185
- "RA_SCZ": header.get("RA_SCZ"),
186
- "DEC_SCZ": header.get("DEC_SCZ"),
187
- }
188
-
189
- # Load GTIs
190
- try:
191
- gti_data = hdu["IBIS-GNRL-GTI"].data
192
- gtis = np.column_stack([gti_data["START"], gti_data["STOP"]])
193
- except (KeyError, IndexError):
194
- # No GTI extension - use full time range
195
- t_start = events_data["TIME"][0]
196
- t_stop = events_data["TIME"][-1]
197
- gtis = np.array([[t_start, t_stop]])
198
-
199
- # Filter bad events (SELECT_FLAG != 0)
200
- good_mask = events_data["SELECT_FLAG"] == 0
201
- events = Table(events_data[good_mask])
202
-
203
- return events, gtis, metadata
204
-
205
-
206
- def default_pif_metadata() -> Dict:
207
- """
208
- Create default PIF metadata for cases without PIF file.
209
-
210
- Used when no PIF weighting is applied. All modules are
211
- considered active with unit weight.
212
-
213
- Returns
214
- -------
215
- metadata : dict
216
- Default PIF metadata with:
217
- - SWID : None
218
- - SRC_RA, SRC_DEC : None (no source position)
219
- - Source_Name : None
220
- - cod : None (no coding fraction)
221
- - No_Modules : [True]*8 (all modules active)
222
-
223
- Examples
224
- --------
225
- >>> meta = default_pif_metadata()
226
- >>> print(meta['No_Modules'])
227
- [True, True, True, True, True, True, True, True]
228
- """
229
- return {
230
- "SWID": None,
231
- "SRC_RA": None,
232
- "SRC_DEC": None,
233
- "Source_Name": None,
234
- "cod": None,
235
- "No_Modules": [True] * 8, # All modules active
236
- }
237
-
238
-
239
- def merge_metadata(events_metadata: Dict, pif_metadata: Dict) -> Dict:
240
- """
241
- Merge events and PIF metadata dictionaries.
242
-
243
- PIF metadata takes precedence except for SWID, which is
244
- preserved from events metadata.
245
-
246
- Parameters
247
- ----------
248
- events_metadata : dict
249
- Metadata from events file.
250
- pif_metadata : dict
251
- Metadata from PIF file.
252
-
253
- Returns
254
- -------
255
- merged : dict
256
- Combined metadata with PIF values overwriting events values
257
- (except SWID).
258
-
259
- Examples
260
- --------
261
- >>> events_meta = {'SWID': '023400100010', 'TSTART': 3000.0}
262
- >>> pif_meta = {'SWID': '999999999999', 'SRC_RA': 83.63, 'SRC_DEC': 22.01}
263
- >>> merged = merge_metadata(events_meta, pif_meta)
264
- >>> print(merged['SWID']) # Preserved from events
265
- 023400100010
266
- >>> print(merged['SRC_RA']) # From PIF
267
- 83.63
268
- """
269
- merged = events_metadata.copy()
270
-
271
- for key, value in pif_metadata.items():
272
- if key != "SWID": # Preserve SWID from events
273
- merged[key] = value
274
-
275
- return merged
276
-
277
-
278
- def load_isgri_pif(
279
- pif_path: Union[str, Path],
280
- events: Table,
281
- pif_threshold: float = 0.5,
282
- pif_extension: int = -1,
283
- ) -> Tuple[Table, NDArray[np.float64], Dict]:
284
- """
285
- Load PIF (Pixel Illumination Fraction) file and apply to events.
286
-
287
- Filters events by PIF threshold and returns PIF weights for
288
- detector response correction.
289
-
290
- Parameters
291
- ----------
292
- pif_path : str or Path
293
- Path to PIF FITS file.
294
- events : Table
295
- Events table from load_isgri_events() with DETZ, DETY columns.
296
- pif_threshold : float, default 0.5
297
- Minimum PIF value to keep event (0.0-1.0).
298
- Higher values = only well-illuminated pixels.
299
- pif_extension : int, default -1
300
- FITS extension index containing PIF data.
301
- -1 = last extension (typical).
302
-
303
- Returns
304
- -------
305
- filtered_events : Table
306
- Events with PIF >= threshold.
307
- pif_weights : ndarray
308
- PIF value for each filtered event (for response correction).
309
- pif_metadata : dict
310
- Metadata with keys:
311
- - SWID : Science Window ID
312
- - Source_ID, Source_Name : Source identifiers
313
- - SRC_RA, SRC_DEC : Source position (degrees)
314
- - cod : Coding fraction (0.0-1.0)
315
- - No_Modules : Array of active module flags
316
-
317
- Raises
318
- ------
319
- ValueError
320
- If PIF file has invalid shape (must be 134×130).
321
- FileNotFoundError
322
- If PIF file not found.
323
-
324
- Examples
325
- --------
326
- >>> events, gtis, meta = load_isgri_events("events.fits")
327
- >>> filtered, weights, pif_meta = load_isgri_pif(
328
- ... "pif_model.fits",
329
- ... events,
330
- ... pif_threshold=0.5
331
- ... )
332
- >>>
333
- >>> print(f"Filtered: {len(filtered)}/{len(events)} events")
334
- >>> print(f"Coding fraction: {pif_meta['cod']:.2%}")
335
- >>> print(f"Active modules: {np.sum(pif_meta['No_Modules'])}/8")
336
-
337
- >>> # Use for light curve with response correction
338
- >>> from isgri.utils import LightCurve
339
- >>> lc = LightCurve(
340
- ... time=filtered['TIME'],
341
- ... energies=filtered['ISGRI_ENERGY'],
342
- ... dety=filtered['DETY'],
343
- ... detz=filtered['DETZ'],
344
- ... weights=weights,
345
- ... gtis=gtis,
346
- ... metadata={**meta, **pif_meta}
347
- ... )
348
-
349
- See Also
350
- --------
351
- load_isgri_events : Load event data
352
- apply_pif_mask : Apply PIF filtering
353
- coding_fraction : Calculate coded fraction
354
- """
355
- if not (0 <= pif_threshold <= 1):
356
- raise ValueError(f"pif_threshold must be in [0, 1], got {pif_threshold}")
357
-
358
- pif_path = Path(pif_path)
359
- if not pif_path.exists():
360
- raise FileNotFoundError(f"PIF file not found: {pif_path}")
361
-
362
- # Load PIF file
363
- with fits.open(pif_path) as hdu:
364
- pif_file = np.array(hdu[pif_extension].data)
365
- header = hdu[pif_extension].header
366
-
367
- # Validate shape
368
- if pif_file.shape != (134, 130):
369
- raise ValueError(
370
- f"Invalid PIF file shape: expected (134, 130), got {pif_file.shape}. " "File may be empty or corrupted."
371
- )
372
-
373
- # Extract metadata
374
- pif_metadata = {
375
- "SWID": header.get("SWID"),
376
- "Source_ID": header.get("SOURCEID"),
377
- "Source_Name": header.get("NAME"),
378
- "SRC_RA": header.get("RA_OBJ"),
379
- "SRC_DEC": header.get("DEC_OBJ"),
380
- }
381
-
382
- # Compute quality metrics
383
- pif_metadata["cod"] = coding_fraction(pif_file, events)
384
- pif_metadata["No_Modules"] = estimate_active_modules(pif_file)
385
-
386
- # Apply PIF mask
387
- filtered_events, pif_weights = apply_pif_mask(pif_file, events, pif_threshold)
388
-
389
- return filtered_events, pif_weights, pif_metadata
1
+ """
2
+ ISGRI Data File Loaders
3
+ ========================
4
+
5
+ Load INTEGRAL/ISGRI event files and detector response (PIF) files.
6
+
7
+ Functions
8
+ ---------
9
+ load_isgri_events : Load photon events from FITS file
10
+ load_isgri_pif : Load and apply detector response (PIF) file
11
+ verify_events_path : Validate and resolve events file path
12
+ default_pif_metadata : Create default PIF metadata
13
+ merge_metadata : Combine events and PIF metadata
14
+
15
+ Examples
16
+ --------
17
+ >>> from isgri.utils import load_isgri_events, load_isgri_pif
18
+ >>>
19
+ >>> # Load events
20
+ >>> events, gtis, metadata = load_isgri_events("isgri_events.fits")
21
+ >>> print(f"Loaded {len(events)} events")
22
+ >>>
23
+ >>> # Apply PIF weighting
24
+ >>> filtered_events, pif_weights, pif_meta = load_isgri_pif(
25
+ ... "pif_model.fits",
26
+ ... events,
27
+ ... pif_threshold=0.5
28
+ ... )
29
+ >>> print(f"Kept {len(filtered_events)}/{len(events)} events")
30
+ >>> print(f"Coding fraction: {pif_meta['cod']:.2%}")
31
+
32
+ Notes
33
+ -----
34
+ - Events files contain photon arrival times, energies, and detector positions
35
+ - PIF files contain detector response maps for specific source positions
36
+ - PIF weighting corrects for shadowing by the coded mask
37
+ """
38
+
39
+ from astropy.io import fits
40
+ from astropy.table import Table
41
+ import numpy as np
42
+ from numpy.typing import NDArray
43
+ from typing import Tuple, Dict, Optional, Union
44
+ from pathlib import Path
45
+ import os
46
+ from .pif import apply_pif_mask, coding_fraction, estimate_active_modules
47
+
48
+
49
+ def verify_events_path(path: Union[str, Path]) -> str:
50
+ """
51
+ Verify and resolve the events file path.
52
+
53
+ Parameters
54
+ ----------
55
+ path : str or Path
56
+ File path or directory path containing events file.
57
+ If directory, searches for files containing 'isgri_events'.
58
+
59
+ Returns
60
+ -------
61
+ resolved_path : str
62
+ Resolved absolute path to valid events file.
63
+
64
+ Raises
65
+ ------
66
+ FileNotFoundError
67
+ If path doesn't exist, no events file found, or multiple
68
+ candidates found in directory.
69
+ ValueError
70
+ If ISGR-EVTS-ALL extension not found in file.
71
+
72
+ Examples
73
+ --------
74
+ >>> # Direct file path
75
+ >>> path = verify_events_path("isgri_events.fits")
76
+
77
+ >>> # Directory with single events file
78
+ >>> path = verify_events_path("/data/scw/0234/001200340010.001")
79
+
80
+ >>> # Will raise error if multiple files
81
+ >>> verify_events_path("/data/scw/") # Multiple SCWs present
82
+ FileNotFoundError: Multiple isgri_events files found...
83
+ """
84
+ path = Path(path)
85
+
86
+ if path.is_file():
87
+ resolved_path = str(path)
88
+ elif path.is_dir():
89
+ candidate_files = [f for f in os.listdir(path) if "isgri_events" in f]
90
+
91
+ if len(candidate_files) == 0:
92
+ raise FileNotFoundError(f"No isgri_events file found in directory: {path}")
93
+ elif len(candidate_files) > 1:
94
+ raise FileNotFoundError(
95
+ f"Multiple isgri_events files found in directory: {path}\n"
96
+ f"Found: {candidate_files}\n"
97
+ "Please specify the exact file path."
98
+ )
99
+ else:
100
+ resolved_path = str(path / candidate_files[0])
101
+ else:
102
+ raise FileNotFoundError(f"Path does not exist: {path}")
103
+
104
+ # Verify FITS structure
105
+ try:
106
+ with fits.open(resolved_path) as hdu:
107
+ if "ISGR-EVTS-ALL" not in hdu:
108
+ raise ValueError(f"Invalid events file: ISGR-EVTS-ALL extension not found in {resolved_path}")
109
+ except Exception as e:
110
+ if isinstance(e, (FileNotFoundError, ValueError)):
111
+ raise
112
+ raise ValueError(f"Cannot open FITS file {resolved_path}: {e}")
113
+
114
+ return resolved_path
115
+
116
+
117
+ def load_isgri_events(events_path: Union[str, Path]) -> Tuple[Table, NDArray[np.float64], Dict]:
118
+ """
119
+ Load ISGRI photon events from FITS file.
120
+
121
+ Parameters
122
+ ----------
123
+ events_path : str or Path
124
+ Path to events FITS file or directory containing it.
125
+
126
+ Returns
127
+ -------
128
+ events : Table
129
+ Astropy Table with columns:
130
+ - TIME : Event time in IJD
131
+ - ISGRI_ENERGY : Energy in keV
132
+ - DETY : Y detector coordinate (0-129)
133
+ - DETZ : Z detector coordinate (0-133)
134
+ gtis : ndarray, shape (N, 2) or None
135
+ Good Time Intervals [start, stop] pairs in IJD.
136
+ If no GTI extension found, returns None.
137
+ metadata : dict
138
+ Header metadata with keys:
139
+ - REVOL : Revolution number
140
+ - SWID : Science Window ID
141
+ - TSTART, TSTOP : Start/stop times (IJD)
142
+ - RA_SCX, DEC_SCX : Pointing axis coordinates
143
+ - RA_SCZ, DEC_SCZ : Z-axis coordinates
144
+ - NoEVTS : Number of events in file
145
+
146
+ Raises
147
+ ------
148
+ FileNotFoundError
149
+ If events file not found or invalid.
150
+ ValueError
151
+ If required FITS extension missing.
152
+
153
+ Examples
154
+ --------
155
+ >>> events, gtis, meta = load_isgri_events("isgri_events.fits")
156
+ >>> print(f"Loaded {len(events)} events")
157
+ >>> print(f"Time range: {meta['TSTART']:.1f} - {meta['TSTOP']:.1f} IJD")
158
+ >>> print(f"GTIs: {len(gtis)} intervals")
159
+
160
+ >>> # Check energy range
161
+ >>> print(f"Energy: {events['ISGRI_ENERGY'].min():.1f} - "
162
+ ... f"{events['ISGRI_ENERGY'].max():.1f} keV")
163
+
164
+ See Also
165
+ --------
166
+ load_isgri_pif : Apply detector response weighting
167
+ """
168
+ confirmed_path = verify_events_path(events_path)
169
+
170
+ with fits.open(confirmed_path) as hdu:
171
+ # Load events
172
+ events_data = hdu["ISGR-EVTS-ALL"].data
173
+ header = hdu["ISGR-EVTS-ALL"].header
174
+
175
+ # Extract metadata
176
+ metadata = {
177
+ "REVOL": header.get("REVOL"),
178
+ "SWID": header.get("SWID"),
179
+ "TSTART": header.get("TSTART"),
180
+ "TSTOP": header.get("TSTOP"),
181
+ "TELAPSE": header.get("TELAPSE"),
182
+ "OBT_TSTART": header.get("OBTSTART"),
183
+ "OBT_TSTOP": header.get("OBTEND"),
184
+ "RA_SCX": header.get("RA_SCX"),
185
+ "DEC_SCX": header.get("DEC_SCX"),
186
+ "RA_SCZ": header.get("RA_SCZ"),
187
+ "DEC_SCZ": header.get("DEC_SCZ"),
188
+ "NoEVTS": header.get("NAXIS2"),
189
+ }
190
+
191
+ # Load GTIs
192
+ try:
193
+ gti_data = hdu["IBIS-GNRL-GTI"].data
194
+ gtis = np.column_stack([gti_data["START"], gti_data["STOP"]])
195
+ except (KeyError, IndexError):
196
+ # No GTI extension - return empty GTI
197
+ # t_start = events_data["TIME"][0]
198
+ # t_stop = events_data["TIME"][-1]
199
+ # gtis = np.array([[t_start, t_stop]])
200
+ gtis = None
201
+
202
+ # Filter bad events (SELECT_FLAG != 0)
203
+ good_mask = events_data["SELECT_FLAG"] == 0
204
+ events = Table(events_data[good_mask])
205
+
206
+ return events, gtis, metadata
207
+
208
+
209
+ def default_pif_metadata() -> Dict:
210
+ """
211
+ Create default PIF metadata for cases without PIF file.
212
+
213
+ Used when no PIF weighting is applied. All modules are
214
+ considered active with unit weight.
215
+
216
+ Returns
217
+ -------
218
+ metadata : dict
219
+ Default PIF metadata with:
220
+ - SWID : None
221
+ - SRC_RA, SRC_DEC : None (no source position)
222
+ - Source_Name : None
223
+ - cod : None (no coding fraction)
224
+ - No_Modules : [True]*8 (all modules active)
225
+
226
+ Examples
227
+ --------
228
+ >>> meta = default_pif_metadata()
229
+ >>> print(meta['No_Modules'])
230
+ [True, True, True, True, True, True, True, True]
231
+ """
232
+ return {
233
+ "SWID": None,
234
+ "SRC_RA": None,
235
+ "SRC_DEC": None,
236
+ "Source_Name": None,
237
+ "cod": None,
238
+ "No_Modules": [True] * 8, # All modules active
239
+ }
240
+
241
+
242
+ def merge_metadata(events_metadata: Dict, pif_metadata: Dict) -> Dict:
243
+ """
244
+ Merge events and PIF metadata dictionaries.
245
+
246
+ PIF metadata takes precedence except for SWID, which is
247
+ preserved from events metadata.
248
+
249
+ Parameters
250
+ ----------
251
+ events_metadata : dict
252
+ Metadata from events file.
253
+ pif_metadata : dict
254
+ Metadata from PIF file.
255
+
256
+ Returns
257
+ -------
258
+ merged : dict
259
+ Combined metadata with PIF values overwriting events values
260
+ (except SWID).
261
+
262
+ Examples
263
+ --------
264
+ >>> events_meta = {'SWID': '023400100010', 'TSTART': 3000.0}
265
+ >>> pif_meta = {'SWID': '999999999999', 'SRC_RA': 83.63, 'SRC_DEC': 22.01}
266
+ >>> merged = merge_metadata(events_meta, pif_meta)
267
+ >>> print(merged['SWID']) # Preserved from events
268
+ 023400100010
269
+ >>> print(merged['SRC_RA']) # From PIF
270
+ 83.63
271
+ """
272
+ merged = events_metadata.copy()
273
+
274
+ for key, value in pif_metadata.items():
275
+ if key != "SWID": # Preserve SWID from events
276
+ merged[key] = value
277
+
278
+ return merged
279
+
280
+
281
+ def load_isgri_pif(
282
+ pif_path: Union[str, Path],
283
+ events: Table,
284
+ pif_threshold: float = 0.5,
285
+ pif_extension: int = -1,
286
+ ) -> Tuple[Table, NDArray[np.float64], Dict]:
287
+ """
288
+ Load PIF (Pixel Illumination Fraction) file and apply to events.
289
+
290
+ Filters events by PIF threshold and returns PIF weights for
291
+ detector response correction.
292
+
293
+ Parameters
294
+ ----------
295
+ pif_path : str or Path
296
+ Path to PIF FITS file.
297
+ events : Table
298
+ Events table from load_isgri_events() with DETZ, DETY columns.
299
+ pif_threshold : float, default 0.5
300
+ Minimum PIF value to keep event (0.0-1.0).
301
+ Higher values = only well-illuminated pixels.
302
+ pif_extension : int, default -1
303
+ FITS extension index containing PIF data.
304
+ -1 = last extension (typical).
305
+
306
+ Returns
307
+ -------
308
+ filtered_events : Table
309
+ Events with PIF >= threshold.
310
+ pif_weights : ndarray
311
+ PIF value for each filtered event (for response correction).
312
+ pif_metadata : dict
313
+ Metadata with keys:
314
+ - SWID : Science Window ID
315
+ - Source_ID, Source_Name : Source identifiers
316
+ - SRC_RA, SRC_DEC : Source position (degrees)
317
+ - cod : Coding fraction (0.0-1.0)
318
+ - No_Modules : Array of active module flags
319
+
320
+ Raises
321
+ ------
322
+ ValueError
323
+ If PIF file has invalid shape (must be 134×130).
324
+ FileNotFoundError
325
+ If PIF file not found.
326
+
327
+ Examples
328
+ --------
329
+ >>> events, gtis, meta = load_isgri_events("events.fits")
330
+ >>> filtered, weights, pif_meta = load_isgri_pif(
331
+ ... "pif_model.fits",
332
+ ... events,
333
+ ... pif_threshold=0.5
334
+ ... )
335
+ >>>
336
+ >>> print(f"Filtered: {len(filtered)}/{len(events)} events")
337
+ >>> print(f"Coding fraction: {pif_meta['cod']:.2%}")
338
+ >>> print(f"Active modules: {np.sum(pif_meta['No_Modules'])}/8")
339
+
340
+ >>> # Use for light curve with response correction
341
+ >>> from isgri.utils import LightCurve
342
+ >>> lc = LightCurve(
343
+ ... time=filtered['TIME'],
344
+ ... energies=filtered['ISGRI_ENERGY'],
345
+ ... dety=filtered['DETY'],
346
+ ... detz=filtered['DETZ'],
347
+ ... weights=weights,
348
+ ... gtis=gtis,
349
+ ... metadata={**meta, **pif_meta}
350
+ ... )
351
+
352
+ See Also
353
+ --------
354
+ load_isgri_events : Load event data
355
+ apply_pif_mask : Apply PIF filtering
356
+ coding_fraction : Calculate coded fraction
357
+ """
358
+ if not (0 <= pif_threshold <= 1):
359
+ raise ValueError(f"pif_threshold must be in [0, 1], got {pif_threshold}")
360
+
361
+ pif_path = Path(pif_path)
362
+ if not pif_path.exists():
363
+ raise FileNotFoundError(f"PIF file not found: {pif_path}")
364
+
365
+ # Load PIF file
366
+ with fits.open(pif_path) as hdu:
367
+ pif_file = np.array(hdu[pif_extension].data)
368
+ header = hdu[pif_extension].header
369
+
370
+ # Validate shape
371
+ if pif_file.shape != (134, 130):
372
+ raise ValueError(
373
+ f"Invalid PIF file shape: expected (134, 130), got {pif_file.shape}. " "File may be empty or corrupted."
374
+ )
375
+
376
+ # Extract metadata
377
+ pif_metadata = {
378
+ "SWID": header.get("SWID"),
379
+ "Source_ID": header.get("SOURCEID"),
380
+ "Source_Name": header.get("NAME"),
381
+ "SRC_RA": header.get("RA_OBJ"),
382
+ "SRC_DEC": header.get("DEC_OBJ"),
383
+ }
384
+
385
+ # Compute quality metrics
386
+ pif_metadata["cod"] = coding_fraction(pif_file, events)
387
+ pif_metadata["No_Modules"] = estimate_active_modules(pif_file)
388
+
389
+ # Apply PIF mask
390
+ filtered_events, pif_weights = apply_pif_mask(pif_file, events, pif_threshold)
391
+
392
+ return filtered_events, pif_weights, pif_metadata