isgri 0.6.1__py3-none-any.whl → 0.7.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.
@@ -43,18 +43,103 @@ from numpy.typing import NDArray
43
43
  from typing import Tuple, Dict, Optional, Union
44
44
  from pathlib import Path
45
45
  import os
46
- from .pif import apply_pif_mask, coding_fraction, estimate_active_modules
46
+ from .pif import coding_fraction, estimate_active_modules
47
+ from ..config import Config
47
48
 
48
49
 
49
- def verify_events_path(path: Union[str, Path]) -> str:
50
+ def resolve_pif_path(
51
+ pif_path: Optional[Union[str, Path]] = None,
52
+ source: Optional[str] = None,
53
+ swid: Optional[str] = None,
54
+ config: Optional[Config] = None,
55
+ ) -> str:
56
+ """
57
+ Resolve PIF file path based on input or source name.
58
+
59
+ Parameters
60
+ ----------
61
+ pif_path : str or Path, optional
62
+ Direct path to PIF file.
63
+ source : str, optional
64
+ Source name to construct default PIF path.
65
+ swid : str, optional
66
+ Science Window ID
67
+ config : Config, optional
68
+ Configuration object containing pif_path.
69
+
70
+ Returns
71
+ -------
72
+ resolved_path : str
73
+ Resolved absolute path to PIF file.
74
+
75
+ Raises
76
+ ------
77
+ FileNotFoundError
78
+ If PIF file not found.
79
+
80
+ Notes
81
+ -----
82
+ - If both pif_path and source are provided, pif_path takes precedence.
83
+ - If neither is provided, raises ValueError.
84
+ """
85
+ if pif_path is not None:
86
+ path = Path(pif_path)
87
+ if path.is_file():
88
+ return str(path)
89
+ if not path.is_dir():
90
+ raise FileNotFoundError(f"PIF path is not a directory: {path}")
91
+
92
+ elif config is not None and config.pif_path is not None:
93
+ path = Path(config.pif_path)
94
+ if not path.is_dir():
95
+ raise FileNotFoundError(f"PIF path from config is not a directory: {path}")
96
+ else:
97
+ raise ValueError("Either pif_path must be provided here or in config.")
98
+
99
+ if swid is None:
100
+ raise ValueError("swid must be provided to resolve PIF file name.")
101
+
102
+ if source is not None:
103
+ candidate_path = path / source
104
+ if candidate_path.is_dir():
105
+ path = candidate_path
106
+
107
+ revol = swid[:4]
108
+ revol_dir = path / revol
109
+ if revol_dir.is_dir():
110
+ path = revol_dir
111
+
112
+ candidate_files = [f for f in os.listdir(path) if f.startswith(swid)]
113
+ if len(candidate_files) == 0:
114
+ raise FileNotFoundError(f"No PIF file found for SWID {swid} in directory: {path}")
115
+ elif len(candidate_files) > 1:
116
+ raise FileNotFoundError(
117
+ f"Multiple PIF files found for SWID {swid} in directory: {path}\n"
118
+ f"Found: {candidate_files}\n"
119
+ "Please specify the exact file path."
120
+ )
121
+ resolved_path = path / candidate_files[0]
122
+ if not resolved_path.is_file():
123
+ raise FileNotFoundError(f"PIF file not found: {resolved_path}")
124
+
125
+ return str(resolved_path)
126
+
127
+
128
+ def resolve_event_path(
129
+ events_path: Optional[Union[str, Path]] = None, swid: Optional[str] = None, config: Optional[Config] = None
130
+ ) -> str:
50
131
  """
51
132
  Verify and resolve the events file path.
52
133
 
53
134
  Parameters
54
135
  ----------
55
- path : str or Path
136
+ events_path : str or Path, optional
56
137
  File path or directory path containing events file.
57
138
  If directory, searches for files containing 'isgri_events'.
139
+ swid : str, optional
140
+ Science Window ID to construct default event path. Requires events_path to be None and defined archive_path in Config.
141
+ config : Config, optional
142
+ Configuration object containing archive_path.
58
143
 
59
144
  Returns
60
145
  -------
@@ -80,16 +165,34 @@ def verify_events_path(path: Union[str, Path]) -> str:
80
165
  >>> # Will raise error if multiple files
81
166
  >>> verify_events_path("/data/scw/") # Multiple SCWs present
82
167
  FileNotFoundError: Multiple isgri_events files found...
168
+
169
+ Notes
170
+ -----
171
+ - If both events_path and swid are provided, events_path takes precedence.
172
+ - If neither is provided, raises ValueError.
173
+ - Using swid requires archive_path in Config and constructs path as:
174
+ {archive_path}/{revol}/{swid}/isgri_events.fits.gz
83
175
  """
84
- path = Path(path)
176
+ if events_path is not None:
177
+ path = Path(events_path)
178
+ if path.is_file():
179
+ return verify_events_file(path)
180
+ elif not path.is_dir():
181
+ raise FileNotFoundError(f"Events path does not exist: {path}")
182
+ elif config is not None and swid is not None:
183
+ if config.archive_path is None:
184
+ raise ValueError("archive_path must be defined in config to resolve events path using swid.")
185
+ path = Path(config.archive_path)
186
+ if not path.is_dir():
187
+ raise FileNotFoundError(f"Archive path from config is not a directory: {path}")
188
+ else:
189
+ raise ValueError("Either events_path must be provided here or in config along with swid.")
85
190
 
86
- if path.is_file():
87
- resolved_path = str(path)
88
- elif path.is_dir():
191
+ if swid is None:
89
192
  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}")
193
+ if len(candidate_files) == 1:
194
+ resolved_path = path / candidate_files[0]
195
+ return verify_events_file(resolved_path)
93
196
  elif len(candidate_files) > 1:
94
197
  raise FileNotFoundError(
95
198
  f"Multiple isgri_events files found in directory: {path}\n"
@@ -97,21 +200,68 @@ def verify_events_path(path: Union[str, Path]) -> str:
97
200
  "Please specify the exact file path."
98
201
  )
99
202
  else:
100
- resolved_path = str(path / candidate_files[0])
203
+ raise FileNotFoundError(f"No isgri_events file found in directory: {path}")
204
+
205
+ revol = swid[:4]
206
+ revol_dir = path / revol
207
+ if revol_dir.is_dir():
208
+ path = revol_dir
209
+ candidate_dirs = [d for d in os.listdir(path) if d.startswith(swid) and (path / d).is_dir()]
210
+ if len(candidate_dirs) == 1:
211
+ path = path / candidate_dirs[0]
212
+ elif len(candidate_dirs) > 1:
213
+ raise FileNotFoundError(
214
+ f"Multiple directories found for SWID {swid} in directory: {path}\n"
215
+ f"Found: {candidate_dirs}\n"
216
+ "Please specify the exact file path."
217
+ )
218
+ else:
219
+ raise FileNotFoundError(f"No directory found for SWID {swid} in directory: {path}")
220
+
221
+ candidate_files = [f for f in os.listdir(path) if "isgri_events" in f]
222
+
223
+ if len(candidate_files) == 0:
224
+ raise FileNotFoundError(f"No isgri_events file found in directory: {path}")
225
+ elif len(candidate_files) > 1:
226
+ raise FileNotFoundError(
227
+ f"Multiple isgri_events files found in directory: {path}\n"
228
+ f"Found: {candidate_files}\n"
229
+ "Please specify the exact file path."
230
+ )
101
231
  else:
102
- raise FileNotFoundError(f"Path does not exist: {path}")
232
+ resolved_path = str(path / candidate_files[0])
233
+
234
+ return verify_events_file(resolved_path)
235
+
236
+
237
+ def verify_events_file(events_path: Union[str, Path]) -> str:
238
+ """
239
+ Verify that the provided events file path is valid.
240
+ Parameters
241
+ ----------
242
+ events_path : str or Path
243
+ Path to events FITS file.
244
+ Returns
245
+ -------
246
+ str
247
+ Validated absolute path to events FITS file.
248
+ Raises
249
+ ------
250
+ FileNotFoundError
251
+ If events file not found.
252
+ ValueError
253
+ If required FITS extension missing.
254
+ """
103
255
 
104
- # Verify FITS structure
105
256
  try:
106
- with fits.open(resolved_path) as hdu:
257
+ with fits.open(events_path) as hdu:
107
258
  if "ISGR-EVTS-ALL" not in hdu:
108
- raise ValueError(f"Invalid events file: ISGR-EVTS-ALL extension not found in {resolved_path}")
259
+ raise ValueError(f"Invalid events file: ISGR-EVTS-ALL extension not found in {events_path}")
109
260
  except Exception as e:
110
261
  if isinstance(e, (FileNotFoundError, ValueError)):
111
262
  raise
112
- raise ValueError(f"Cannot open FITS file {resolved_path}: {e}")
113
-
114
- return resolved_path
263
+ raise ValueError(f"Cannot open FITS file {events_path}: {e}")
264
+ return str(events_path)
115
265
 
116
266
 
117
267
  def load_isgri_events(events_path: Union[str, Path]) -> Tuple[Table, NDArray[np.float64], Dict]:
@@ -165,9 +315,8 @@ def load_isgri_events(events_path: Union[str, Path]) -> Tuple[Table, NDArray[np.
165
315
  --------
166
316
  load_isgri_pif : Apply detector response weighting
167
317
  """
168
- confirmed_path = verify_events_path(events_path)
169
318
 
170
- with fits.open(confirmed_path) as hdu:
319
+ with fits.open(events_path) as hdu:
171
320
  # Load events
172
321
  events_data = hdu["ISGR-EVTS-ALL"].data
173
322
  header = hdu["ISGR-EVTS-ALL"].header
@@ -281,7 +430,6 @@ def merge_metadata(events_metadata: Dict, pif_metadata: Dict) -> Dict:
281
430
  def load_isgri_pif(
282
431
  pif_path: Union[str, Path],
283
432
  events: Table,
284
- pif_threshold: float = 0.5,
285
433
  pif_extension: int = -1,
286
434
  ) -> Tuple[Table, NDArray[np.float64], Dict]:
287
435
  """
@@ -296,9 +444,6 @@ def load_isgri_pif(
296
444
  Path to PIF FITS file.
297
445
  events : Table
298
446
  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
447
  pif_extension : int, default -1
303
448
  FITS extension index containing PIF data.
304
449
  -1 = last extension (typical).
@@ -352,12 +497,8 @@ def load_isgri_pif(
352
497
  See Also
353
498
  --------
354
499
  load_isgri_events : Load event data
355
- apply_pif_mask : Apply PIF filtering
356
500
  coding_fraction : Calculate coded fraction
357
501
  """
358
- if not (0 <= pif_threshold <= 1):
359
- raise ValueError(f"pif_threshold must be in [0, 1], got {pif_threshold}")
360
-
361
502
  pif_path = Path(pif_path)
362
503
  if not pif_path.exists():
363
504
  raise FileNotFoundError(f"PIF file not found: {pif_path}")
@@ -387,6 +528,6 @@ def load_isgri_pif(
387
528
  pif_metadata["No_Modules"] = estimate_active_modules(pif_file)
388
529
 
389
530
  # Apply PIF mask
390
- filtered_events, pif_weights = apply_pif_mask(pif_file, events, pif_threshold)
531
+ pif_weights = pif_file[events["DETZ"], events["DETY"]]
391
532
 
392
- return filtered_events, pif_weights, pif_metadata
533
+ return events, pif_weights, pif_metadata
isgri/utils/lightcurve.py CHANGED
@@ -32,8 +32,16 @@ from numpy.typing import NDArray
32
32
  from typing import Optional, Union, Tuple, List
33
33
  from pathlib import Path
34
34
  import os
35
- from .file_loaders import load_isgri_events, load_isgri_pif, default_pif_metadata, merge_metadata
35
+ from .file_loaders import (
36
+ resolve_event_path,
37
+ load_isgri_events,
38
+ resolve_pif_path,
39
+ load_isgri_pif,
40
+ default_pif_metadata,
41
+ merge_metadata,
42
+ )
36
43
  from .pif import DETZ_BOUNDS, DETY_BOUNDS
44
+ from ..config import Config
37
45
 
38
46
 
39
47
  class LightCurve:
@@ -103,7 +111,9 @@ class LightCurve:
103
111
  detz: NDArray[np.float64],
104
112
  weights: NDArray[np.float64],
105
113
  metadata: dict,
106
- ) -> None:
114
+ use_pif: bool = True,
115
+ pif_threshold: float = 0.5,
116
+ ):
107
117
  """
108
118
  Initialize LightCurve instance.
109
119
 
@@ -133,14 +143,19 @@ class LightCurve:
133
143
  self.detz = detz
134
144
  self.weights = weights
135
145
  self.metadata = metadata
146
+ self.use_pif = bool(use_pif and np.any(weights != 1.0))
147
+ self.pif_threshold = pif_threshold
148
+ self._cached_weights = None
149
+ self._cached_pif_settings = (self.use_pif, self.pif_threshold)
136
150
 
137
151
  @classmethod
138
152
  def load_data(
139
153
  cls,
140
154
  events_path: Optional[Union[str, Path]] = None,
141
155
  pif_path: Optional[Union[str, Path]] = None,
142
- scw: Optional[str] = None,
156
+ swid: Optional[str] = None,
143
157
  source: Optional[str] = None,
158
+ use_pif: bool = True,
144
159
  pif_threshold: float = 0.5,
145
160
  pif_extension: int = -1,
146
161
  ) -> "LightCurve":
@@ -150,7 +165,7 @@ class LightCurve:
150
165
  Args:
151
166
  events_path (str): The path to the events file or directory.
152
167
  pif_path (str, optional): The path to the PIF file. Defaults to None.
153
- scw (str, optional): SCW identifier for auto-path resolution. Defaults to None.
168
+ swid (str, optional): Science Window ID for auto-path resolution. Defaults to None.
154
169
  source (str, optional): Source name for auto-path resolution. Defaults to None.
155
170
  pif_threshold (float, optional): The PIF threshold value. Defaults to 0.5.
156
171
  pif_extension (int, optional): PIF file extension index. Defaults to -1.
@@ -158,12 +173,18 @@ class LightCurve:
158
173
  Returns:
159
174
  LightCurve: An instance of the LightCurve class.
160
175
  """
161
- events, gtis, metadata = load_isgri_events(events_path)
162
- if pif_path:
176
+ if swid is not None or source is not None:
177
+ cfg = Config()
178
+ else:
179
+ cfg = None
180
+
181
+ confirmed_events_path = resolve_event_path(events_path, swid, cfg)
182
+ events, gtis, metadata = load_isgri_events(confirmed_events_path)
183
+ if pif_path is not None or source is not None:
163
184
  if pif_threshold < 0 or pif_threshold > 1:
164
185
  raise ValueError(f"pif_threshold must be in [0, 1], got {pif_threshold}")
165
-
166
- events, weights, metadata_pif = load_isgri_pif(pif_path, events, pif_threshold, pif_extension)
186
+ pif_path = resolve_pif_path(pif_path, source, metadata["SWID"], cfg)
187
+ events, weights, metadata_pif = load_isgri_pif(pif_path, events, pif_extension)
167
188
  else:
168
189
  weights = np.ones(len(events))
169
190
  metadata_pif = default_pif_metadata()
@@ -172,7 +193,7 @@ class LightCurve:
172
193
  time = events["TIME"]
173
194
  energies = events["ISGRI_ENERGY"]
174
195
  dety, detz = events["DETY"], events["DETZ"]
175
- return cls(time, energies, gtis, dety, detz, weights, metadata)
196
+ return cls(time, energies, gtis, dety, detz, weights, metadata, use_pif, pif_threshold)
176
197
 
177
198
  def rebin(
178
199
  self,
@@ -181,6 +202,8 @@ class LightCurve:
181
202
  emax: float,
182
203
  local_time: bool = True,
183
204
  custom_mask: Optional[NDArray[np.bool_]] = None,
205
+ use_pif: Optional[bool] = None,
206
+ pif_threshold: Optional[float] = None,
184
207
  ) -> Tuple[NDArray[np.float64], NDArray[np.float64]]:
185
208
  """
186
209
  Rebins the events with the specified bin size and energy range.
@@ -224,10 +247,13 @@ class LightCurve:
224
247
  # Create bins
225
248
  bins, binsize_actual = self._create_bins(binsize, time, t0, local_time)
226
249
 
250
+ # Get weights
251
+ weights = self._get_weights(use_pif, pif_threshold)
252
+
227
253
  # Apply filters
228
254
  mask = self._create_event_mask(emin, emax, custom_mask)
229
255
  time_filtered = time[mask]
230
- weights_filtered = self.weights[mask]
256
+ weights_filtered = weights[mask]
231
257
 
232
258
  # Histogram
233
259
  counts, bin_edges = np.histogram(time_filtered, bins=bins, weights=weights_filtered)
@@ -265,6 +291,42 @@ class LightCurve:
265
291
 
266
292
  return bins, binsize_actual
267
293
 
294
+ def _get_weights(
295
+ self, use_pif: Optional[bool] = None, pif_threshold: Optional[float] = None
296
+ ) -> NDArray[np.float64]:
297
+ """
298
+ Get event weights based on PIF settings.
299
+
300
+ Parameters
301
+ ----------
302
+ use_pif : bool, optional
303
+ Override instance use_pif setting
304
+ pif_threshold : float, optional
305
+ Override instance pif_threshold setting
306
+
307
+ Returns
308
+ -------
309
+ weights : ndarray
310
+ Event weights (0 if below threshold, PIF value otherwise, or 1.0 if no PIF)
311
+ """
312
+ if use_pif is None:
313
+ use_pif = self.use_pif
314
+ if pif_threshold is None:
315
+ pif_threshold = self.pif_threshold
316
+
317
+ cache_key = (use_pif, pif_threshold)
318
+ if cache_key == self._cached_pif_settings and self._cached_weights is not None:
319
+ return self._cached_weights
320
+
321
+ if not use_pif:
322
+ weights = np.ones_like(self.weights)
323
+ else:
324
+ weights = np.where(self.weights > pif_threshold, self.weights, 0.0)
325
+
326
+ self._cached_pif_settings = cache_key
327
+ self._cached_weights = weights
328
+ return weights
329
+
268
330
  def _create_event_mask(
269
331
  self,
270
332
  emin: float,
@@ -298,6 +360,8 @@ class LightCurve:
298
360
  emax: float,
299
361
  local_time: bool = True,
300
362
  custom_mask: Optional[NDArray[np.bool_]] = None,
363
+ use_pif: Optional[bool] = None,
364
+ pif_threshold: Optional[float] = None,
301
365
  ) -> Tuple[NDArray[np.float64], List[NDArray[np.float64]]]:
302
366
  """
303
367
  Rebins the events by all 8 detector modules with the specified bin size and energy range.
@@ -337,7 +401,9 @@ class LightCurve:
337
401
  time_filtered = time[energy_mask]
338
402
  dety_filtered = self.dety[energy_mask]
339
403
  detz_filtered = self.detz[energy_mask]
340
- weights_filtered = self.weights[energy_mask]
404
+
405
+ weights = self._get_weights(use_pif, pif_threshold)
406
+ weights_filtered = weights[energy_mask]
341
407
 
342
408
  # Compute module indices using digitize
343
409
  dety_bin = np.digitize(dety_filtered, DETY_BOUNDS) - 1 # 0 or 1
@@ -358,6 +424,8 @@ class LightCurve:
358
424
  emin: float,
359
425
  emax: float,
360
426
  local_time: bool = True,
427
+ use_pif: Optional[bool] = None,
428
+ pif_threshold: Optional[float] = None,
361
429
  ) -> float:
362
430
  """
363
431
  Calculates the counts in the specified time and energy range.
@@ -373,7 +441,29 @@ class LightCurve:
373
441
  float: The total counts in the specified range.
374
442
  """
375
443
  time = self.local_time if local_time else self.time
376
- return np.sum(self.weights[(time >= t1) & (time < t2) & (self.energies >= emin) & (self.energies < emax)])
444
+ weights = self._get_weights(use_pif, pif_threshold)
445
+ return np.sum(weights[(time >= t1) & (time < t2) & (self.energies >= emin) & (self.energies < emax)])
446
+
447
+ def get(
448
+ self, use_pif: Optional[bool] = None, pif_threshold: Optional[float] = None
449
+ ) -> Tuple[
450
+ NDArray[np.float64], NDArray[np.float64], NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]
451
+ ]:
452
+ """
453
+ Returns the time, energies, dety, detz, weights arrays with filtering above PIF threshold applied.
454
+ Args:
455
+ use_pif (bool, optional): Override instance use_pif setting
456
+ pif_threshold (float, optional): Override instance pif_threshold setting
457
+ """
458
+ weights = self._get_weights(use_pif, pif_threshold)
459
+ mask = weights > 0.0
460
+ return (
461
+ self.time[mask],
462
+ self.energies[mask],
463
+ self.dety[mask],
464
+ self.detz[mask],
465
+ weights[mask],
466
+ )
377
467
 
378
468
  def ijd2loc(self, ijd_time: Union[float, NDArray[np.float64]]) -> Union[float, NDArray[np.float64]]:
379
469
  """
@@ -405,5 +495,5 @@ class LightCurve:
405
495
  f"LightCurve(n_events={len(self.time)}, "
406
496
  f"time_range=({self.time[0]:.3f}, {self.time[-1]:.3f}) IJD, "
407
497
  f"energy_range=({self.energies.min():.1f}, {self.energies.max():.1f}) keV, "
408
- f"scw={self.metadata.get('SWID', 'Unknown')})"
498
+ f"scw={self.metadata.get('SWID', 'Unknown')}), use_pif={self.use_pif}, pif_threshold={self.pif_threshold})"
409
499
  )
isgri/utils/pif.py CHANGED
@@ -36,6 +36,7 @@ import numpy as np
36
36
  from numpy.typing import NDArray
37
37
  from typing import Tuple
38
38
  from astropy.table import Table
39
+ import warnings
39
40
 
40
41
  # ISGRI detector module boundaries (mm coordinates)
41
42
  # 8 modules total: 4 rows × 2 columns
@@ -99,9 +100,16 @@ def apply_pif_mask(
99
100
  events: Table,
100
101
  pif_threshold: float = 0.5,
101
102
  ) -> Tuple[Table, NDArray[np.float64]]:
103
+
102
104
  """
103
105
  Filter events by PIF threshold and return PIF weights.
104
106
 
107
+ .. deprecated::
108
+ This function is deprecated. PIF filtering is now handled
109
+ automatically by LightCurve methods using the `use_pif` and
110
+ `pif_threshold` attributes. Use `LightCurve.load_data()` instead.
111
+
112
+
105
113
  Events with PIF < threshold are removed. Remaining events are
106
114
  weighted by their PIF values for response correction.
107
115
 
@@ -138,6 +146,12 @@ def apply_pif_mask(
138
146
  >>> print(f"Kept {len(filtered)}/{len(events)} events")
139
147
  >>> print(f"Mean weight: {weights.mean():.3f}")
140
148
  """
149
+ warnings.warn(
150
+ "apply_pif_mask() is deprecated. Use LightCurve.load_data() with "
151
+ "use_pif and pif_threshold parameters instead.",
152
+ DeprecationWarning,
153
+ stacklevel=2,
154
+ )
141
155
  # Validate inputs
142
156
  if not (0 <= pif_threshold <= 1):
143
157
  raise ValueError(f"pif_threshold must be in [0, 1], got {pif_threshold}")
isgri/utils/quality.py CHANGED
@@ -366,9 +366,10 @@ class QualityMetrics:
366
366
  time, counts = data["time"], data["counts"]
367
367
 
368
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
369
+ if self.lightcurve is not None:
370
+ gtis = self.lightcurve.gtis
371
+ if gtis is None or len(gtis) == 0:
372
+ raise ValueError("GTIs must be provided or available in lightcurve")
372
373
 
373
374
  # Check for overlap
374
375
  if gtis[0, 0] > time[-1] or gtis[-1, 1] < time[0]: