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/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
- A class for working with ISGRI events. Works fully with and without ISGRI model file (PIF file).
11
-
12
- Attributes:
13
- time (ndarray): The IJD time values of the events.
14
- energies (ndarray): The energy values of the events in keV.
15
- gtis (ndarray): The Good Time Intervals (GTIs) of the events.
16
- t0 (float): The first time of the events (IJD).
17
- local_time (ndarray): The local time values of the events (seconds from t0).
18
- dety (ndarray): The Y detector coordinates.
19
- detz (ndarray): The Z detector coordinates.
20
- pif (ndarray): The PIF values of the events.
21
- metadata (dict): Event metadata including SWID, source info, etc.
22
-
23
- Methods:
24
- load_data: Loads the light curve data from events and PIF files.
25
- rebin: Rebins the light curve with specified bin size and energy range.
26
- rebin_by_modules: Rebins the light curve for all 8 detector modules.
27
- cts: Calculates the counts in specified time and energy range.
28
- ijd2loc: Converts IJD time to local time (seconds from t0).
29
- loc2ijd: Converts local time to IJD time.
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
- def __init__(self, time, energies, gtis, dety, detz, weights, metadata):
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
- Args:
37
- time (ndarray): IJD time values.
38
- energies (ndarray): Energy values in keV.
39
- gtis (ndarray): Good Time Intervals.
40
- dety (ndarray): Y detector coordinates.
41
- detz (ndarray): Z detector coordinates.
42
- pif (ndarray): PIF mask values.
43
- metadata (dict): Event metadata.
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(cls, events_path=None, pif_path=None, scw=None, source=None, pif_threshold=0.5, pif_extension=-1):
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(self, binsize, emin, emax, local_time=True, custom_mask=None):
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(self, binsize, time, t0, local_time):
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(self, emin, emax, custom_mask=None):
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(self, binsize, emin, emax, local_time=True, custom_mask=None):
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(self, t1, t2, emin, emax, local_time=True, bkg=False):
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
- DETZ_BOUNDS, DETY_BOUNDS = [0, 32, 66, 100, 134], [0, 64, 130] # Detector module boundaries
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
- 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]
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
- def apply_pif_mask(pif_file, events, pif_threshold=0.5):
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
- piffed_events = events[pif_filter[events["DETZ"], events["DETY"]]]
17
- pif = pif_file[piffed_events["DETZ"], piffed_events["DETY"]]
18
- return piffed_events, pif
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(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
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
- 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
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