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.
@@ -0,0 +1,3 @@
1
+ from .scwquery import ScwQuery
2
+
3
+ __all__ = ["ScwQuery"]
@@ -0,0 +1,517 @@
1
+ from astropy.table import Table
2
+ from astropy.coordinates import SkyCoord
3
+ from astropy.time import Time
4
+ from astropy import units as u
5
+ import numpy as np
6
+ from pathlib import Path
7
+ from typing import Optional, Union, Literal
8
+ from dataclasses import dataclass
9
+ from isgri.utils import ijd2utc, utc2ijd
10
+ from .wcs import compute_detector_offset
11
+
12
+
13
+ @dataclass
14
+ class Filter:
15
+ """Filter with mask and parameters"""
16
+
17
+ name: str
18
+ mask: np.ndarray
19
+ params: dict
20
+
21
+
22
+ class ScwQuery:
23
+ """
24
+ Query interface for INTEGRAL SCW catalog.
25
+
26
+ Parameters
27
+ ----------
28
+ catalog_path : str or Path
29
+ Path to SCW catalog FITS file
30
+
31
+ Examples
32
+ --------
33
+ >>> query = ScwQuery("data/scw_catalog.fits")
34
+ >>> results = query.time(tstart=3000).quality(max_chi=2.0).get()
35
+ >>>
36
+ >>> # FOV-based filtering
37
+ >>> results = query.position(ra=83.63, dec=22.01, fov_mode="full").get()
38
+
39
+ See Also
40
+ --------
41
+ time : Filter by time range
42
+ quality : Filter by data quality
43
+ position : Filter by sky position
44
+ revolution : Filter by revolution number
45
+ """
46
+
47
+ ISGRI_FULLY_CODED = 4.0 # half-width in degrees (8x8 total)
48
+ ISGRI_DETECTOR_EDGE = 14.5 # half-width in degrees (29x29 total)
49
+
50
+ def __init__(self, catalog_path: Union[str, Path]):
51
+ self.catalog_path = Path(catalog_path)
52
+ self._catalog: Optional[Table] = None
53
+ self._mask: Optional[np.ndarray] = None
54
+ self._filters: list[Filter] = []
55
+
56
+ @property
57
+ def catalog(self) -> Table:
58
+ """Lazy load catalog from FITS file"""
59
+ if self._catalog is None:
60
+ if not self.catalog_path.exists():
61
+ raise FileNotFoundError(f"Catalog not found: {self.catalog_path}")
62
+ self._catalog = Table.read(self.catalog_path)
63
+ self._validate_catalog()
64
+ return self._catalog
65
+
66
+ def _validate_catalog(self):
67
+ """Check required columns exist"""
68
+ required = ["SWID", "TSTART", "TSTOP", "RA_SCX", "DEC_SCX", "RA_SCZ", "DEC_SCZ", "CHI"]
69
+ missing = [col for col in required if col not in self._catalog.colnames]
70
+ if missing:
71
+ raise ValueError(f"Catalog missing required columns: {missing}")
72
+
73
+ @property
74
+ def mask(self) -> np.ndarray:
75
+ """Initialize mask if needed"""
76
+ if self._mask is None:
77
+ self._mask = np.ones(len(self.catalog), dtype=bool)
78
+ return self._mask
79
+
80
+ def time(
81
+ self, tstart: Optional[Union[float, str]] = None, tstop: Optional[Union[float, str]] = None
82
+ ) -> "ScwQuery":
83
+ """
84
+ Filter by time range.
85
+
86
+ Parameters
87
+ ----------
88
+ tstart : float or str, optional
89
+ Start time in IJD (float) or ISO format (str)
90
+ tstop : float or str, optional
91
+ Stop time in IJD (float) or ISO format (str)
92
+
93
+ Returns
94
+ -------
95
+ ScwQuery
96
+ Self for method chaining
97
+
98
+ Examples
99
+ --------
100
+ >>> query.time(tstart="2010-01-01", tstop="2010-12-31")
101
+ >>> query.time(tstart=3000.0) # IJD format
102
+ >>> query.time(tstop="2015-01-01") # Only upper bound
103
+ """
104
+ mask = np.ones(len(self.catalog), dtype=bool)
105
+
106
+ if tstart is not None:
107
+ tstart_ijd = self._parse_time(tstart)
108
+ mask &= self.catalog["TSTOP"] >= tstart_ijd
109
+
110
+ if tstop is not None:
111
+ tstop_ijd = self._parse_time(tstop)
112
+ mask &= self.catalog["TSTART"] <= tstop_ijd
113
+
114
+ self._add_filter(Filter(name="time", mask=mask, params={"tstart": tstart, "tstop": tstop}))
115
+ return self
116
+
117
+ def quality(self, max_chi: Optional[float] = None, chi_type: str = "CHI") -> "ScwQuery":
118
+ """
119
+ Filter by quality metric (lower chi-squared means better quality).
120
+
121
+ Parameters
122
+ ----------
123
+ max_chi : float, optional
124
+ Maximum chi-squared value to accept
125
+ chi_type : str, default "CHI"
126
+ Column name: "CHI", "CUT_CHI", or "GTI_CHI"
127
+
128
+ Returns
129
+ -------
130
+ ScwQuery
131
+ Self for method chaining
132
+
133
+ Examples
134
+ --------
135
+ >>> query.quality(max_chi=2.0) # High quality data
136
+ >>> query.quality(max_chi=5.0, chi_type="CUT_CHI") # Alternative metric
137
+
138
+ """
139
+ if chi_type not in self.catalog.colnames:
140
+ raise ValueError(f"Column {chi_type} not found in catalog")
141
+
142
+ mask = np.ones(len(self.catalog), dtype=bool)
143
+
144
+ if max_chi is not None:
145
+ if max_chi <= 0:
146
+ raise ValueError("max_chi must be positive")
147
+ mask &= self.catalog[chi_type] <= max_chi
148
+
149
+ self._add_filter(Filter(name="quality", mask=mask, params={"max_chi": max_chi, "chi_type": chi_type}))
150
+ return self
151
+
152
+ def position(
153
+ self,
154
+ ra: Optional[Union[float, str]] = None,
155
+ dec: Optional[Union[float, str]] = None,
156
+ radius: Optional[float] = None,
157
+ target: Optional[SkyCoord] = None,
158
+ fov_mode: Optional[Literal["full", "any"]] = None,
159
+ max_offset: Optional[float] = None,
160
+ ) -> "ScwQuery":
161
+ """
162
+ Filter by sky position using angular separation or FOV constraints.
163
+
164
+ Parameters
165
+ ----------
166
+ ra : float or str, optional
167
+ Right ascension in degrees or HMS format
168
+ dec : float or str, optional
169
+ Declination in degrees or DMS format
170
+ radius : float, optional
171
+ Angular separation radius in degrees (simple cone search)
172
+ target : SkyCoord, optional
173
+ Target position as SkyCoord (alternative to ra/dec)
174
+ fov_mode : {'full', 'any'}, optional
175
+ FOV filtering mode using detector coordinates:
176
+ - 'full': fully coded FOV (both |Y| and |Z| <= 4 deg)
177
+ - 'any': detector FOV (both |Y| and |Z| <= 14.5 deg)
178
+ max_offset : float, optional
179
+ Custom maximum offset in degrees (uses max of |Y|, |Z|)
180
+
181
+ Returns
182
+ -------
183
+ ScwQuery
184
+ Self for method chaining
185
+
186
+ Notes
187
+ -----
188
+ When fov_mode or max_offset is specified, uses compute_detector_offset() to calculate
189
+ detector Y/Z offsets from pointing center. Otherwise uses simple angular
190
+ separation from X-axis pointing.
191
+
192
+ Examples
193
+ --------
194
+ >>> query.position(ra=83.63, dec=22.01, radius=5.0)
195
+ >>> query.position(ra=83.63, dec=22.01, fov_mode="full")
196
+ >>> query.position(ra="05h34m31s", dec="+22d00m52s", fov_mode="any")
197
+ """
198
+ if target is None:
199
+ if ra is None or dec is None:
200
+ return self
201
+ target = self._parse_position(ra, dec)
202
+
203
+ if isinstance(ra, (int, float)) and not (0 <= ra < 360):
204
+ raise ValueError(f"RA must be in [0, 360), got {ra}")
205
+
206
+ if isinstance(dec, (int, float)) and not (-90 <= dec <= 90):
207
+ raise ValueError(f"Dec must be in [-90, 90], got {dec}")
208
+
209
+ if radius is not None and radius <= 0:
210
+ raise ValueError("radius must be positive")
211
+
212
+ if fov_mode is not None and fov_mode not in ['full', 'any']:
213
+ raise ValueError(f"Invalid fov_mode: {fov_mode}. Use 'full' or 'any'")
214
+
215
+ mask = np.ones(len(self.catalog), dtype=bool)
216
+
217
+ if fov_mode is not None or max_offset is not None:
218
+ y_off, z_off, max_off = self._compute_detector_offsets(target)
219
+
220
+ if fov_mode == "full":
221
+ mask &= (np.abs(y_off) <= self.ISGRI_FULLY_CODED) & (np.abs(z_off) <= self.ISGRI_FULLY_CODED)
222
+ filter_params = {
223
+ "ra": target.ra.deg,
224
+ "dec": target.dec.deg,
225
+ "fov_mode": "full",
226
+ "max_offset": self.ISGRI_FULLY_CODED,
227
+ "y_offset": y_off,
228
+ "z_offset": z_off,
229
+ "max_offset_actual": max_off,
230
+ }
231
+
232
+ elif fov_mode == "any":
233
+ mask &= (np.abs(y_off) <= self.ISGRI_DETECTOR_EDGE) & (np.abs(z_off) <= self.ISGRI_DETECTOR_EDGE)
234
+ filter_params = {
235
+ "ra": target.ra.deg,
236
+ "dec": target.dec.deg,
237
+ "fov_mode": "any",
238
+ "max_offset": self.ISGRI_DETECTOR_EDGE,
239
+ "y_offset": y_off,
240
+ "z_offset": z_off,
241
+ "max_offset_actual": max_off,
242
+ }
243
+
244
+ elif max_offset is not None:
245
+ mask &= max_off <= max_offset
246
+ filter_params = {
247
+ "ra": target.ra.deg,
248
+ "dec": target.dec.deg,
249
+ "fov_mode": "custom",
250
+ "max_offset": max_offset,
251
+ "y_offset": y_off,
252
+ "z_offset": z_off,
253
+ "max_offset_actual": max_off,
254
+ }
255
+
256
+ else:
257
+ pointings_x = SkyCoord(self.catalog["RA_SCX"], self.catalog["DEC_SCX"], unit="deg")
258
+ separations = target.separation(pointings_x).deg
259
+
260
+ if radius is not None:
261
+ if radius <= 0:
262
+ raise ValueError("radius must be positive")
263
+ mask &= separations <= radius
264
+
265
+ filter_params = {
266
+ "ra": target.ra.deg,
267
+ "dec": target.dec.deg,
268
+ "radius": radius,
269
+ "separations": separations,
270
+ }
271
+
272
+ self._add_filter(Filter(name="position", mask=mask, params=filter_params))
273
+ return self
274
+
275
+ def revolution(self, revolutions: Union[int, str, list[Union[int, str]]]) -> "ScwQuery":
276
+ """
277
+ Filter by revolution number(s).
278
+
279
+ Parameters
280
+ ----------
281
+ revolutions : int, str, or list
282
+ Revolution number(s) as integer (255), 4-digit string ("0255"),
283
+ or list of mixed types
284
+
285
+ Returns
286
+ -------
287
+ ScwQuery
288
+ Self for method chaining
289
+
290
+ Examples
291
+ --------
292
+ >>> query.revolution(255)
293
+ >>> query.revolution("0255")
294
+ >>> query.revolution([255, "0256", 300])
295
+ """
296
+ if not isinstance(revolutions, list):
297
+ revolutions = [revolutions]
298
+
299
+ rev_ints = []
300
+ for rev in revolutions:
301
+ if isinstance(rev, int):
302
+ rev_ints.append(rev)
303
+ elif isinstance(rev, str):
304
+ if len(rev) != 4:
305
+ raise ValueError(f"Revolution string must be 4 digits: '{rev}'")
306
+ try:
307
+ rev_ints.append(int(rev))
308
+ except ValueError:
309
+ raise ValueError(f"Invalid revolution string: '{rev}'")
310
+ else:
311
+ raise TypeError(f"Revolution must be int or str, got {type(rev)}")
312
+
313
+ mask = np.isin(self.catalog["REVOL"], rev_ints)
314
+ self._add_filter(Filter(name="revolution", mask=mask, params={"revolutions": rev_ints}))
315
+ return self
316
+
317
+ def get(self) -> Table:
318
+ """
319
+ Apply all filters and return filtered catalog.
320
+
321
+ Returns
322
+ -------
323
+ Table
324
+ Filtered catalog as astropy Table
325
+
326
+ Notes
327
+ -----
328
+ This is typically the final call in a filter chain:
329
+
330
+ Examples
331
+ --------
332
+ >>> results = query.time(tstart=3000).quality(max_chi=2.0).get()
333
+ >>> print(len(results))
334
+ """
335
+ combined_mask = self.mask.copy()
336
+ for filt in self._filters:
337
+ combined_mask &= filt.mask
338
+ return self.catalog[combined_mask]
339
+
340
+ def count(self) -> int:
341
+ """
342
+ Count SCWs matching current filters.
343
+
344
+ Returns
345
+ -------
346
+ int
347
+ Number of matching SCWs
348
+
349
+ Examples
350
+ --------
351
+ >>> query.time(tstart=3000).count()
352
+ 150
353
+ >>> # Faster than len(query.get()) for large catalogs
354
+ """
355
+ return len(self.get())
356
+
357
+ def reset(self) -> "ScwQuery":
358
+ """
359
+ Clear all filters and reset to full catalog.
360
+
361
+ Returns
362
+ -------
363
+ ScwQuery
364
+ Self for method chaining
365
+
366
+ Examples
367
+ --------
368
+ >>> query.time(tstart=3000).get() # First query
369
+ >>> query.reset() # Clear filters
370
+ >>> query.quality(max_chi=2.0).get() # New query
371
+ """
372
+ self._filters.clear()
373
+ self._mask = None
374
+ return self
375
+
376
+ def _compute_detector_offsets(self, target: SkyCoord) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
377
+ """
378
+ Compute detector Y/Z offsets using compute_detector_offset.
379
+
380
+ Parameters
381
+ ----------
382
+ target : SkyCoord
383
+ Target sky position
384
+
385
+ Returns
386
+ -------
387
+ y_offset : ndarray
388
+ Y-axis offsets in degrees
389
+ z_offset : ndarray
390
+ Z-axis offsets in degrees
391
+ max_offset : ndarray
392
+ Maximum of |Y| and |Z| offsets
393
+ """
394
+ y_off, z_off = compute_detector_offset(
395
+ target.ra.deg,
396
+ target.dec.deg,
397
+ self.catalog["RA_SCX"],
398
+ self.catalog["DEC_SCX"],
399
+ self.catalog["RA_SCZ"],
400
+ self.catalog["DEC_SCZ"],
401
+ )
402
+ max_off = np.maximum(np.abs(y_off), np.abs(z_off))
403
+ return y_off, z_off, max_off
404
+
405
+ def _add_filter(self, filter: Filter):
406
+ """Replace existing filter with same name or add new filter"""
407
+ self._filters = [f for f in self._filters if f.name != filter.name]
408
+ self._filters.append(filter)
409
+
410
+ def _parse_time(self, time: Union[float, str]) -> float:
411
+ """
412
+ Parse time to IJD format.
413
+
414
+ Parameters
415
+ ----------
416
+ time : float or str
417
+ Time as IJD (< 51544), MJD (>= 51544), or ISO string
418
+
419
+ Returns
420
+ -------
421
+ float
422
+ Time in IJD format
423
+ """
424
+ if isinstance(time, (int, float)):
425
+ return time if time < 51544 else time - 51544
426
+ if isinstance(time, str):
427
+ return utc2ijd(time)
428
+ raise TypeError(f"Invalid time type: {type(time)}")
429
+
430
+ def _parse_position(self, ra: Union[float, str], dec: Union[float, str]) -> SkyCoord:
431
+ """
432
+ Parse coordinates to SkyCoord.
433
+
434
+ Parameters
435
+ ----------
436
+ ra : float or str
437
+ Right ascension as degrees or HMS string
438
+ dec : float or str
439
+ Declination as degrees or DMS string
440
+
441
+ Returns
442
+ -------
443
+ SkyCoord
444
+ Parsed coordinate
445
+ """
446
+ if isinstance(ra, (int, float)) and isinstance(dec, (int, float)):
447
+ return SkyCoord(ra, dec, unit="deg")
448
+
449
+ if isinstance(ra, str) and isinstance(dec, str):
450
+ try:
451
+ return SkyCoord(ra, dec, unit=(u.hourangle, u.deg))
452
+ except:
453
+ try:
454
+ return SkyCoord(ra, dec, unit="deg")
455
+ except Exception as e:
456
+ raise ValueError(f"Could not parse position: {ra}, {dec}") from e
457
+
458
+ raise TypeError(f"Invalid position types: {type(ra)}, {type(dec)}")
459
+
460
+ @property
461
+ def filters_summary(self) -> dict:
462
+ """
463
+ Get summary of applied filters.
464
+
465
+ Returns
466
+ -------
467
+ dict
468
+ Dictionary mapping filter names to their parameters
469
+ """
470
+ return {f.name: f.params for f in self._filters}
471
+
472
+ def get_offsets(self, ra: Union[float, str], dec: Union[float, str]) -> Table:
473
+ """
474
+ Get filtered catalog with detector offsets computed.
475
+
476
+ Parameters
477
+ ----------
478
+ ra : float or str
479
+ Right ascension
480
+ dec : float or str
481
+ Declination
482
+
483
+ Returns
484
+ -------
485
+ Table
486
+ Filtered catalog with Y_OFFSET, Z_OFFSET, MAX_OFFSET columns added
487
+
488
+ Examples
489
+ --------
490
+ >>> results = query.time(tstart=3000).get_offsets(ra=83.63, dec=22.01)
491
+ >>> fully_coded = results[results['MAX_OFFSET'] <= 4.0]
492
+ """
493
+ target = self._parse_position(ra, dec)
494
+ y_off, z_off, max_off = self._compute_detector_offsets(target)
495
+
496
+ result = self.get()
497
+ combined_mask = self._get_combined_mask()
498
+ result["Y_OFFSET"] = y_off[combined_mask]
499
+ result["Z_OFFSET"] = z_off[combined_mask]
500
+ result["MAX_OFFSET"] = max_off[combined_mask]
501
+ return result
502
+
503
+ def _get_combined_mask(self) -> np.ndarray:
504
+ """Get combined mask from all active filters"""
505
+ combined_mask = self.mask.copy()
506
+ for filt in self._filters:
507
+ combined_mask &= filt.mask
508
+ return combined_mask
509
+
510
+ def __repr__(self) -> str:
511
+ n_total = len(self.catalog)
512
+ n_selected = self.count()
513
+ return (
514
+ f"ScwQuery(catalog={self.catalog_path.name}, "
515
+ f"total={n_total}, selected={n_selected}, "
516
+ f"filters={list(self.filters_summary.keys())})"
517
+ )
isgri/catalog/wcs.py ADDED
@@ -0,0 +1,190 @@
1
+ """
2
+ WCS coordinate transformations for celestial coordinates.
3
+
4
+ Implements spherical coordinate rotations following Calabretta & Greisen (2002),
5
+ "Representations of celestial coordinates in FITS", A&A 395, 1077-1122.
6
+ https://doi.org/10.1051/0004-6361:20021327
7
+ """
8
+
9
+ from typing import Union
10
+ import numpy.typing as npt
11
+ import numpy as np
12
+
13
+
14
+ def spherical_to_cartesian(lon, lat):
15
+ """
16
+ Convert spherical coordinates to Cartesian unit vectors.
17
+
18
+ Args:
19
+ lon: Longitude in degrees
20
+ lat: Latitude in degrees
21
+
22
+ Returns:
23
+ tuple: (x, y, z) Cartesian coordinates on unit sphere
24
+ """
25
+ lon_rad = np.radians(lon)
26
+ lat_rad = np.radians(lat)
27
+
28
+ cos_lat = np.cos(lat_rad)
29
+ x = cos_lat * np.cos(lon_rad)
30
+ y = cos_lat * np.sin(lon_rad)
31
+ z = np.sin(lat_rad)
32
+
33
+ return x, y, z
34
+
35
+
36
+ def cartesian_to_spherical(x, y, z):
37
+ """
38
+ Convert Cartesian unit vectors to spherical coordinates.
39
+
40
+ Args:
41
+ x, y, z: Cartesian coordinates
42
+
43
+ Returns:
44
+ tuple: (lon, lat) in degrees
45
+ """
46
+ # Clamp z to valid range for arcsin
47
+ z = np.clip(z, -1.0, 1.0)
48
+
49
+ lat = np.degrees(np.arcsin(z))
50
+ lon = np.degrees(np.arctan2(y, x))
51
+
52
+ return lon, lat
53
+
54
+
55
+ def rotation_matrix(alpha_p, delta_p, phi_p=np.pi):
56
+ """
57
+ Compute rotation matrix for coordinate transformation.
58
+
59
+ Following Calabretta & Greisen (2002), equations (5) and (7).
60
+ Assumes theta_0 = 90° (most common case).
61
+
62
+ Args:
63
+ alpha_p: Reference point RA in radians
64
+ delta_p: Reference point Dec in radians
65
+ phi_p: Native longitude of celestial pole (default: π for standard orientation)
66
+
67
+ Returns:
68
+ ndarray: 3x3 rotation matrix
69
+ """
70
+ sa = np.sin(alpha_p)
71
+ ca = np.cos(alpha_p)
72
+ sd = np.sin(delta_p)
73
+ cd = np.cos(delta_p)
74
+ sp = np.sin(phi_p)
75
+ cp = np.cos(phi_p)
76
+
77
+ # Rotation matrix from Calabretta & Greisen (2002), eq. (5)
78
+ R = np.array(
79
+ [
80
+ [-sa * sp - ca * cp * sd, ca * sp - sa * cp * sd, cp * cd],
81
+ [sa * cp - ca * sp * sd, -ca * cp - sa * sp * sd, sp * cd],
82
+ [ca * cd, sa * cd, sd],
83
+ ]
84
+ )
85
+
86
+ return R
87
+
88
+
89
+ def celestial_to_native(lon, lat, crval, longpole=180.0):
90
+ """
91
+ Transform from celestial (RA/Dec) to native spherical coordinates.
92
+
93
+ Args:
94
+ lon: Celestial longitude (RA) in degrees
95
+ lat: Celestial latitude (Dec) in degrees
96
+ crval: Reference point [RA, Dec] in degrees
97
+ longpole: Native longitude of celestial north pole (default: 180°)
98
+
99
+ Returns:
100
+ tuple: (phi, theta) native coordinates in degrees
101
+ """
102
+ alpha_p = np.radians(crval[0])
103
+ delta_p = np.radians(crval[1])
104
+ phi_p = np.radians(longpole)
105
+
106
+ x, y, z = spherical_to_cartesian(lon, lat)
107
+
108
+ R = rotation_matrix(alpha_p, delta_p, phi_p)
109
+ x_rot = R[0, 0] * x + R[0, 1] * y + R[0, 2] * z
110
+ y_rot = R[1, 0] * x + R[1, 1] * y + R[1, 2] * z
111
+ z_rot = R[2, 0] * x + R[2, 1] * y + R[2, 2] * z
112
+
113
+ phi, theta = cartesian_to_spherical(x_rot, y_rot, z_rot)
114
+
115
+ return phi, theta
116
+
117
+
118
+ def native_to_celestial(phi, theta, crval, longpole=180.0):
119
+ """
120
+ Transform from native spherical to celestial (RA/Dec) coordinates.
121
+
122
+ Args:
123
+ phi: Native longitude in degrees
124
+ theta: Native latitude in degrees
125
+ crval: Reference point [RA, Dec] in degrees
126
+ longpole: Native longitude of celestial north pole (default: 180°)
127
+
128
+ Returns:
129
+ tuple: (lon, lat) celestial coordinates in degrees
130
+ """
131
+ alpha_p = np.radians(crval[0])
132
+ delta_p = np.radians(crval[1])
133
+ phi_p = np.radians(longpole)
134
+
135
+ x, y, z = spherical_to_cartesian(phi, theta)
136
+
137
+ R = rotation_matrix(alpha_p, delta_p, phi_p).T # Transpose for inverse rotation
138
+ x_rot = R[0, 0] * x + R[0, 1] * y + R[0, 2] * z
139
+ y_rot = R[1, 0] * x + R[1, 1] * y + R[1, 2] * z
140
+ z_rot = R[2, 0] * x + R[2, 1] * y + R[2, 2] * z
141
+
142
+ lon, lat = cartesian_to_spherical(x_rot, y_rot, z_rot)
143
+
144
+ return lon, lat
145
+
146
+
147
+ def compute_detector_offset(
148
+ src_ra: Union[float, npt.ArrayLike],
149
+ src_dec: Union[float, npt.ArrayLike],
150
+ pointing_ra: float,
151
+ pointing_dec: float,
152
+ z_ra: float,
153
+ z_dec: float,
154
+ ) -> tuple[Union[float, np.ndarray], Union[float, np.ndarray]]:
155
+ """
156
+ Compute source offset in INTEGRAL detector coordinates.
157
+
158
+ Args:
159
+ src_ra: Source RA in degrees
160
+ src_dec: Source Dec in degrees
161
+ pointing_ra: Pointing axis RA in degrees
162
+ pointing_dec: Pointing axis Dec in degrees
163
+ z_ra: Z-axis RA in degrees
164
+ z_dec: Z-axis Dec in degrees
165
+
166
+ Returns:
167
+ tuple: (y_offset, z_offset) in degrees (absolute values)
168
+ """
169
+ # Transform Z-axis to native coordinates to get roll angle
170
+ scZ_phi, _ = celestial_to_native(z_ra, z_dec, [pointing_ra, pointing_dec])
171
+ roll = scZ_phi - 180.0
172
+
173
+ # Transform source to native coordinates
174
+ phi, theta = celestial_to_native(src_ra, src_dec, [pointing_ra, pointing_dec])
175
+
176
+ # Convert to detector coordinates
177
+ # theta is elevation from pointing axis
178
+ theta = 90.0 - theta
179
+
180
+ # phi is azimuth, correct for roll
181
+ phi = phi + 90.0 - roll
182
+
183
+ # Project onto detector Y and Z axes
184
+ theta_rad = np.radians(theta)
185
+ phi_rad = np.radians(phi)
186
+
187
+ y = np.degrees(np.arctan(np.tan(theta_rad) * np.cos(phi_rad)))
188
+ z = np.degrees(np.arctan(np.tan(theta_rad) * np.sin(phi_rad)))
189
+
190
+ return np.abs(y), np.abs(z)