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/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)
isgri/cli.py ADDED
@@ -0,0 +1,224 @@
1
+ import click
2
+ from pathlib import Path
3
+ from .catalog import ScwQuery
4
+ from .__version__ import __version__
5
+ from .config import Config
6
+
7
+
8
+ @click.group()
9
+ @click.version_option(version=__version__)
10
+ def main():
11
+ """ISGRI - INTEGRAL/ISGRI data analysis toolkit."""
12
+ pass
13
+
14
+
15
+ def parse_time(time_str):
16
+ """
17
+ Parse time string as IJD float or ISO date string.
18
+
19
+ Parameters
20
+ ----------
21
+ time_str : str or None
22
+ Time as "YYYY-MM-DD" or IJD number
23
+
24
+ Returns
25
+ -------
26
+ float or str or None
27
+ Parsed time value
28
+ """
29
+ if time_str is None:
30
+ return None
31
+
32
+ try:
33
+ return float(time_str)
34
+ except ValueError:
35
+ return time_str
36
+
37
+
38
+ @main.command()
39
+ @click.option("--catalog", type=click.Path(), help="Path to catalog FITS file. If not provided, uses config value.")
40
+ @click.option("--tstart", help="Start time (YYYY-MM-DD or IJD)")
41
+ @click.option("--tstop", help="Stop time (YYYY-MM-DD or IJD)")
42
+ @click.option("--ra", type=float, help="Right ascension (degrees)")
43
+ @click.option("--dec", type=float, help="Declination (degrees)")
44
+ @click.option("--fov", type=click.Choice(["full", "any"]), default="any", help="Field of view mode")
45
+ @click.option("--max-chi", type=float, help="Maximum chi-squared value")
46
+ @click.option("--chi-type", type=click.Choice(["RAW", "CUT", "GTI"]), default="CUT", help="Type of chi-squared value")
47
+ @click.option("--revolution", "-r", help="Revolution number")
48
+ @click.option("--output", "-o", type=click.Path(), help="Output file (.fits or .csv)")
49
+ @click.option("--list-swids", is_flag=True, help="Only output SWID list")
50
+ @click.option("--count", is_flag=True, help="Only show count")
51
+ def query(catalog, tstart, tstop, ra, dec, fov, max_chi, chi_type, revolution, output, list_swids, count):
52
+ """
53
+ Query INTEGRAL science window catalog.
54
+
55
+ If no catalog path is provided, uses the default from configuration.
56
+ Multiple filters can be combined.
57
+
58
+ Examples:
59
+ Query by time range (IJD):
60
+
61
+ isgri query --tstart 3000 --tstop 3100
62
+
63
+ Query by time range (ISO date):
64
+
65
+ isgri query --tstart 2010-01-01 --tstop 2010-12-31
66
+
67
+ Query by sky position:
68
+
69
+ isgri query --ra 83.63 --dec 22.01 --fov full
70
+
71
+ Query with quality cut:
72
+
73
+ isgri query --max-chi 2.0 --chi-type CUT
74
+
75
+ Save results to file:
76
+
77
+ isgri query --tstart 3000 --tstop 3100 --output results.fits
78
+
79
+ Get only SWID list:
80
+
81
+ isgri query --tstart 3000 --tstop 3100 --list-swids
82
+
83
+ Count matching science windows:
84
+
85
+ isgri query --ra 83.63 --dec 22.01 --count
86
+ """
87
+ try:
88
+ # Load catalog
89
+ q = ScwQuery(catalog)
90
+ initial_count = len(q.catalog)
91
+
92
+ # Parse times (handle both IJD and ISO)
93
+ tstart = parse_time(tstart)
94
+ tstop = parse_time(tstop)
95
+
96
+ # Apply filters
97
+ if tstart or tstop:
98
+ q = q.time(tstart=tstart, tstop=tstop)
99
+
100
+ if ra is not None and dec is not None:
101
+ q = q.position(ra=ra, dec=dec, fov_mode=fov)
102
+
103
+ if max_chi is not None:
104
+ q = q.quality(max_chi=max_chi, chi_type=chi_type)
105
+
106
+ if revolution:
107
+ q = q.revolution(revolution)
108
+
109
+ results = q.get()
110
+
111
+ if count:
112
+ click.echo(len(results))
113
+
114
+ elif list_swids:
115
+ for swid in results["SWID"]:
116
+ click.echo(swid)
117
+
118
+ elif output:
119
+ if output.endswith(".csv"):
120
+ results.write(output, format="ascii.csv", overwrite=True)
121
+ else:
122
+ results.write(output, format="fits", overwrite=True)
123
+ click.echo(f"Saved {len(results)} SCWs to {output}")
124
+
125
+ else:
126
+ click.echo(f"Found {len(results)}/{initial_count} SCWs")
127
+ if len(results) > 0:
128
+ display_cols = ["SWID", "TSTART", "TSTOP", "RA_SCX", "DEC_SCX"]
129
+ chi_col = f"{chi_type}_CHI" if chi_type != "RAW" else "CHI"
130
+ if chi_col in results.colnames:
131
+ display_cols.append(chi_col)
132
+ click.echo(results[display_cols][:10])
133
+ if len(results) > 10:
134
+ click.echo(f"... and {len(results) - 10} more")
135
+
136
+ except Exception as e:
137
+ click.echo(f"Error: {e}", err=True)
138
+ raise click.Abort()
139
+
140
+
141
+ @main.command()
142
+ def config():
143
+ """
144
+ Show current configuration.
145
+
146
+ Displays paths to config file, archive directory, and catalog file,
147
+ along with their existence status.
148
+ """
149
+ cfg = Config()
150
+
151
+ click.echo(f"Config file: {cfg.path}")
152
+ click.echo(f" Exists: {cfg.path.exists()}")
153
+ click.echo()
154
+
155
+ archive = cfg.archive_path
156
+ click.echo(f"Archive path: {archive if archive else '(not set)'}")
157
+ if archive:
158
+ click.echo(f" Exists: {archive.exists()}")
159
+
160
+ try:
161
+ catalog = cfg.catalog_path
162
+ click.echo(f"Catalog path: {catalog if catalog else '(not set)'}")
163
+ if catalog:
164
+ click.echo(f" Exists: {catalog.exists()}")
165
+ except FileNotFoundError as e:
166
+ click.echo(f"Catalog path: (configured but file not found)")
167
+ click.echo(f" Error: {e}")
168
+
169
+
170
+ @main.command()
171
+ @click.option("--archive", type=click.Path(), help="INTEGRAL archive directory path")
172
+ @click.option("--catalog", type=click.Path(), help="Catalog FITS file path")
173
+ def config_set(archive, catalog):
174
+ """
175
+ Set configuration values.
176
+
177
+ Set default paths for archive directory and/or catalog file.
178
+ Paths are expanded (~ becomes home directory) and resolved to absolute paths.
179
+ Warns if path doesn't exist but allows setting anyway.
180
+
181
+ Examples:
182
+
183
+ Set archive path:
184
+
185
+ isgri config-set --archive /anita/archivio/
186
+
187
+ Set catalog path:
188
+
189
+ isgri config-set --catalog ~/data/scw_catalog.fits
190
+
191
+ Set both at once:
192
+
193
+ isgri config-set --archive /anita/archivio/ --catalog ~/data/scw_catalog.fits
194
+ """
195
+ if not archive and not catalog:
196
+ click.echo("Error: Specify at least one option (--archive or --catalog)", err=True)
197
+ raise click.Abort()
198
+
199
+ cfg = Config()
200
+
201
+ if archive:
202
+ archive_path = Path(archive).expanduser().resolve()
203
+ if not archive_path.exists():
204
+ click.echo(f"Warning: Archive path does not exist: {archive_path}", err=True)
205
+ if not click.confirm("Set anyway?"):
206
+ raise click.Abort()
207
+ cfg.set(archive_path=archive_path)
208
+ click.echo(f"✓ Archive path set to: {archive_path}")
209
+
210
+ if catalog:
211
+ catalog_path = Path(catalog).expanduser().resolve()
212
+ if not catalog_path.exists():
213
+ click.echo(f"Warning: Catalog file does not exist: {catalog_path}", err=True)
214
+ if not click.confirm("Set anyway?"):
215
+ raise click.Abort()
216
+ cfg.set(catalog_path=catalog_path)
217
+ click.echo(f"✓ Catalog path set to: {catalog_path}")
218
+
219
+ click.echo()
220
+ click.echo(f"Configuration saved to: {cfg.path}")
221
+
222
+
223
+ if __name__ == "__main__":
224
+ main()
isgri/config.py ADDED
@@ -0,0 +1,151 @@
1
+ import sys
2
+ from pathlib import Path
3
+ from typing import Optional
4
+ from platformdirs import user_config_dir
5
+
6
+ if sys.version_info >= (3, 11):
7
+ import tomllib
8
+ else:
9
+ import tomli as tomllib
10
+
11
+ import tomli_w
12
+
13
+
14
+ class Config:
15
+ """
16
+ Configuration manager for ISGRI.
17
+
18
+ Manages paths to archive directory and catalog. Config is stored
19
+ in platform-specific location (~/.config/isgri/config.toml on Linux).
20
+ Falls back to local isgri_config.toml if global config doesn't exist.
21
+
22
+ Parameters
23
+ ----------
24
+ path : Path, optional
25
+ Custom config file path. If not provided, uses platform default.
26
+
27
+ Attributes
28
+ ----------
29
+ path : Path
30
+ Path to config file
31
+ archive_path : Path or None
32
+ Path to INTEGRAL archive directory
33
+ catalog_path : Path or None
34
+ Path to catalog FITS file (validated on access)
35
+ """
36
+
37
+ DEFAULT_PATH = Path(user_config_dir("isgri")) / "config.toml"
38
+
39
+ def __init__(self, path: Optional[Path] = None):
40
+ self.path = path or self.DEFAULT_PATH
41
+ self._config = None
42
+
43
+ @property
44
+ def config(self) -> dict:
45
+ """
46
+ Load and return config dictionary.
47
+
48
+ Returns
49
+ -------
50
+ dict
51
+ Configuration dictionary
52
+ """
53
+ if self._config is not None:
54
+ return self._config
55
+
56
+ if self.path.exists():
57
+ path = self.path
58
+ elif self.path == self.DEFAULT_PATH and Path("isgri_config.toml").exists():
59
+ print("Config file not found at default path, using local isgri_config.toml instead.", file=sys.stderr)
60
+ path = Path("isgri_config.toml")
61
+ else:
62
+ self._config = {}
63
+ return self._config
64
+
65
+ with open(path, "rb") as f:
66
+ self._config = tomllib.load(f)
67
+
68
+ return self._config
69
+
70
+ @property
71
+ def archive_path(self) -> Optional[Path]:
72
+ """
73
+ Get archive directory path from config.
74
+
75
+ Returns
76
+ -------
77
+ Path or None
78
+ Path to archive directory. No validation performed.
79
+ """
80
+ path_str = self.config.get("archive_path")
81
+ if path_str:
82
+ return Path(path_str)
83
+ return None
84
+
85
+ @property
86
+ def catalog_path(self) -> Optional[Path]:
87
+ """
88
+ Get catalog path from config.
89
+
90
+ Returns
91
+ -------
92
+ Path or None
93
+ Path to catalog FITS file
94
+
95
+ Raises
96
+ ------
97
+ FileNotFoundError
98
+ If configured path doesn't exist
99
+ """
100
+ path_str = self.config.get("catalog_path")
101
+ if not path_str:
102
+ return None
103
+ path = Path(path_str)
104
+ if not path.exists():
105
+ raise FileNotFoundError(f"Catalog path does not exist: {path}")
106
+ return path
107
+
108
+ def save(self):
109
+ """Save current config to file."""
110
+ self.path.parent.mkdir(parents=True, exist_ok=True)
111
+ with open(self.path, "wb") as f:
112
+ tomli_w.dump(self._config or {}, f)
113
+
114
+ def create_new(self, archive_path: Optional[Path] = None, catalog_path: Optional[Path] = None):
115
+ """
116
+ Create new config file with given paths.
117
+
118
+ Parameters
119
+ ----------
120
+ archive_path : Path, optional
121
+ Path to archive directory
122
+ catalog_path : Path, optional
123
+ Path to catalog FITS file
124
+ """
125
+ self._config = {}
126
+ if archive_path:
127
+ self._config["archive_path"] = str(archive_path)
128
+ if catalog_path:
129
+ self._config["catalog_path"] = str(catalog_path)
130
+ self.save()
131
+
132
+ def set(self, archive_path: Optional[Path] = None, catalog_path: Optional[Path] = None):
133
+ """
134
+ Update config paths and save.
135
+
136
+ Parameters
137
+ ----------
138
+ archive_path : Path, optional
139
+ New archive directory path
140
+ catalog_path : Path, optional
141
+ New catalog path
142
+ """
143
+ if archive_path:
144
+ self.config["archive_path"] = str(archive_path)
145
+ if catalog_path:
146
+ self.config["catalog_path"] = str(catalog_path)
147
+
148
+ self.save()
149
+
150
+ def __repr__(self):
151
+ return f"Config(path={self.path}, archive={self.archive_path}, catalog={self.catalog_path})"