rms-starcat 1.0.2__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.
starcat/starcatalog.py ADDED
@@ -0,0 +1,530 @@
1
+ ################################################################################
2
+ # starcat/starcatalog.py
3
+ ################################################################################
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections.abc import Iterator
8
+ import inspect
9
+ import numpy as np
10
+ from typing import Any, Optional, no_type_check
11
+
12
+
13
+ AS_TO_DEG = 1 / 3600.
14
+ AS_TO_RAD = np.radians(AS_TO_DEG)
15
+ MAS_TO_DEG = AS_TO_DEG / 1000.
16
+ MAS_TO_RAD = np.radians(MAS_TO_DEG)
17
+ YEAR_TO_SEC = 1 / 365.25 / 86400.
18
+
19
+ TWOPI = 2 * np.pi
20
+ HALFPI = np.pi / 2
21
+
22
+ #===============================================================================
23
+ #
24
+ # Jacobson B-V photometry vs. stellar spectral classification
25
+ #
26
+ # Data from
27
+ # Zombeck, M. V. Handbook of Space Astronomy and Astrophysics,
28
+ # Cambridge, UK: Cambridge University Press, 2nd ed., pp. 68-70
29
+ # http://ads.harvard.edu/books/hsaa/
30
+ # as transcribed:
31
+ # http://www.vendian.org/mncharity/dir3/starcolor/details.html
32
+ #
33
+ # The tables below only include main sequence stars, but the temperature
34
+ # difference between main sequence stars and giant stars is minimal for our
35
+ # purposes. Missing values have been linearly interpolated.
36
+ #
37
+ #===============================================================================
38
+
39
+ SCLASS_TO_B_MINUS_V = {
40
+ 'O5': -0.32,
41
+ 'O6': -0.32,
42
+ 'O7': -0.32,
43
+ 'O8': -0.31,
44
+ 'O9': -0.31,
45
+ 'O9.5': -0.30,
46
+ 'B0': -0.30,
47
+ 'B0.5': -0.28,
48
+ 'B1': -0.26,
49
+ 'B2': -0.24,
50
+ 'B3': -0.20,
51
+ 'B4': -0.18, # Interpolated
52
+ 'B5': -0.16,
53
+ 'B6': -0.14,
54
+ 'B7': -0.12,
55
+ 'B8': -0.09,
56
+ 'B9': -0.06,
57
+ 'A0': +0.00,
58
+ 'A1': +0.02, # Interpolated
59
+ 'A2': +0.04, # Interpolated
60
+ 'A3': +0.06,
61
+ 'A4': +0.10, # Interpolated
62
+ 'A5': +0.14,
63
+ 'A6': +0.16, # Interpolated
64
+ 'A7': +0.19,
65
+ 'A8': +0.23, # Interpolated
66
+ 'A9': +0.27, # Interpolated
67
+ 'F0': +0.31,
68
+ 'F1': +0.33, # Interpolated
69
+ 'F2': +0.36,
70
+ 'F3': +0.38, # Interpolated
71
+ 'F4': +0.41, # Interpolated
72
+ 'F5': +0.43,
73
+ 'F6': +0.47, # Interpolated
74
+ 'F7': +0.51, # Interpolated
75
+ 'F8': +0.54,
76
+ 'F9': +0.56, # Interpolated
77
+ 'G0': +0.59,
78
+ 'G1': +0.61, # Interpolated
79
+ 'G2': +0.63,
80
+ 'G3': +0.64, # Interpolated
81
+ 'G4': +0.65, # Interpolated
82
+ 'G5': +0.66,
83
+ 'G6': +0.69, # Interpolated
84
+ 'G7': +0.72, # Interpolated
85
+ 'G8': +0.74,
86
+ 'G9': +0.78, # Interpolated
87
+ 'K0': +0.82,
88
+ 'K1': +0.87, # Interpolated
89
+ 'K2': +0.92,
90
+ 'K3': +0.99, # Interpolated
91
+ 'K4': +1.07, # Interpolated
92
+ 'K5': +1.15,
93
+ 'K6': +1.22, # Interpolated
94
+ 'K7': +1.30,
95
+ 'K8': +1.33, # Interpolated
96
+ 'K9': +1.37, # Interpolated
97
+ 'M0': +1.41,
98
+ 'M1': +1.48,
99
+ 'M2': +1.52,
100
+ 'M3': +1.55,
101
+ 'M4': +1.56,
102
+ 'M5': +1.61,
103
+ 'M6': +1.72,
104
+ 'M7': +1.84,
105
+ 'M8': +2.00
106
+ }
107
+
108
+ SCLASS_TO_SURFACE_TEMP = {
109
+ 'O5': 38000,
110
+ 'O6': 38000,
111
+ 'O7': 38000,
112
+ 'O8': 35000,
113
+ 'O9': 35000,
114
+ 'O9.5': 31900,
115
+ 'B0': 30000,
116
+ 'B0.5': 27000,
117
+ 'B1': 24200,
118
+ 'B2': 22100,
119
+ 'B3': 18800,
120
+ 'B4': 17600, # Interpolated
121
+ 'B5': 16400,
122
+ 'B6': 15400,
123
+ 'B7': 14500,
124
+ 'B8': 13400,
125
+ 'B9': 12400,
126
+ 'A0': 10800,
127
+ 'A1': 10443, # Interpolated
128
+ 'A2': 10086, # Interpolated
129
+ 'A3': 9730,
130
+ 'A4': 9175, # Interpolated
131
+ 'A5': 8620,
132
+ 'A6': 8405, # Interpolated
133
+ 'A7': 8190,
134
+ 'A8': 7873, # Interpolated
135
+ 'A9': 7557, # Interpolated
136
+ 'F0': 7240,
137
+ 'F1': 7085, # Interpolated
138
+ 'F2': 6930,
139
+ 'F3': 6800, # Interpolated
140
+ 'F4': 6670, # Interpolated
141
+ 'F5': 6540,
142
+ 'F6': 6427, # Interpolated
143
+ 'F7': 6313, # Interpolated
144
+ 'F8': 6200,
145
+ 'F9': 6060, # Interpolated
146
+ 'G0': 5920,
147
+ 'G1': 5850, # Interpolated
148
+ 'G2': 5780,
149
+ 'G3': 5723, # Interpolated
150
+ 'G4': 5667, # Interpolated
151
+ 'G5': 5610,
152
+ 'G6': 5570, # Interpolated
153
+ 'G7': 5530, # Interpolated
154
+ 'G8': 5490,
155
+ 'G9': 5365, # Interpolated
156
+ 'K0': 5240,
157
+ 'K1': 5010, # Interpolated
158
+ 'K2': 4780,
159
+ 'K3': 4706, # Interpolated
160
+ 'K4': 4632, # Interpolated
161
+ 'K5': 4558, # Interpolated
162
+ 'K6': 4484, # Interpolated
163
+ 'K7': 4410,
164
+ 'K8': 4247, # Interpolated
165
+ 'K9': 4083, # Interpolated
166
+ 'M0': 3800, # M class from https://arxiv.org/abs/0903.3371
167
+ 'M1': 3600,
168
+ 'M2': 3400,
169
+ 'M3': 3250,
170
+ 'M4': 3100,
171
+ 'M5': 2800,
172
+ 'M6': 2600,
173
+ 'M7': 2500,
174
+ 'M8': 2300,
175
+ }
176
+
177
+
178
+ #===============================================================================
179
+ #
180
+ # STAR Superclass
181
+ #
182
+ #===============================================================================
183
+
184
+ class Star:
185
+ """A holder for star attributes.
186
+
187
+ This is the base class that defines attributes common to all or most
188
+ star catalogs. Note that fields vary among star catalogs and individual
189
+ stars and there is no guarantee a particular field will be filled in.
190
+ """
191
+
192
+ def __init__(self) -> None:
193
+ """Constructor for Star; additional attributes are available from subclasses."""
194
+
195
+ self.unique_number: Optional[int] = None
196
+ """Unique catalog number (may not be unique across catalogs)"""
197
+
198
+ self.ra: Optional[float] = None
199
+ """Right ascension at J2000 epoch (radians)"""
200
+
201
+ self.ra_sigma: Optional[float] = None
202
+ """Right ascension uncertainty (radians)"""
203
+
204
+ self.rac_sigma: Optional[float] = None
205
+ """Right ascension * cos(DEC) uncertainty (radians)"""
206
+
207
+ self.dec: Optional[float] = None
208
+ """Declination at J2000 epoch (radians)"""
209
+
210
+ self.dec_sigma: Optional[float] = None
211
+ """Declination uncertainty (radians)"""
212
+
213
+ self.vmag: Optional[float] = None
214
+ """Visual magnitude"""
215
+
216
+ self.vmag_sigma: Optional[float] = None
217
+ """Visual magnitude uncertainty"""
218
+
219
+ self.pm_ra: Optional[float] = None
220
+ """Proper motion in RA (radians/sec)"""
221
+
222
+ self.pm_ra_sigma: Optional[float] = None
223
+ """Proper motion in RA uncertainty (radians/sec)"""
224
+
225
+ self.pm_rac: Optional[float] = None
226
+ """Proper motion in RA * cos(DEC) (radians/sec)"""
227
+
228
+ self.pm_rac_sigma: Optional[float] = None
229
+ """Proper motion in RA * cos(DEC) uncertainty (radians/sec)"""
230
+
231
+ self.pm_dec: Optional[float] = None
232
+ """Proper motion in DEC (radians/sec)"""
233
+
234
+ self.pm_dec_sigma: Optional[float] = None
235
+ """Proper motion in DEC error (radians/sec)"""
236
+
237
+ self.spectral_class: Optional[str] = None
238
+ """Spectral class"""
239
+
240
+ self.temperature: Optional[float] = None
241
+ """Star temperature (usually derived from spectral class)"""
242
+
243
+ def __str__(self) -> str:
244
+
245
+ ret = f'UNIQUE ID {self.unique_number:d}'
246
+
247
+ if self.ra is not None:
248
+ ret += f' | RA {np.degrees(self.ra):.7f}°'
249
+ if self.ra_sigma is not None:
250
+ ret += f' [+/- {np.degrees(self.ra_sigma):.7f}°]'
251
+
252
+ ra_deg = np.degrees(self.ra)/15 # In hours
253
+ hh = int(ra_deg)
254
+ mm = int((ra_deg-hh)*60)
255
+ ss = (ra_deg-hh-mm/60.)*3600
256
+ ret += f' ({hh:02d}h{mm:02d}m{ss:05.3f}s'
257
+ if self.ra_sigma is not None:
258
+ ret += ' +/- %.4fs' % (np.degrees(self.ra_sigma)*3600)
259
+ ret += ')'
260
+
261
+ if self.dec is not None:
262
+ ret += f' | DEC {np.degrees(self.dec):.7f}°'
263
+ if self.dec_sigma is not None:
264
+ ret += f' [+/- {np.degrees(self.dec_sigma):.7f}°]'
265
+
266
+ dec_deg = np.degrees(self.dec)
267
+ neg = '+'
268
+ if dec_deg < 0.:
269
+ neg = '-'
270
+ dec_deg = -dec_deg
271
+ dd = int(dec_deg)
272
+ mm = int((dec_deg-dd)*60)
273
+ ss = (dec_deg-dd-mm/60.)*3600
274
+ ret += f' ({neg}{dd:03d}d{mm:02d}m{ss:05.3f}s'
275
+
276
+ if self.dec_sigma is not None:
277
+ ret += ' +/- %.4fs' % (np.degrees(self.dec_sigma)*3600)
278
+ ret += ')'
279
+
280
+ ret += '\n'
281
+
282
+ if self.vmag is not None:
283
+ ret += f'VMAG {self.vmag:6.3f} '
284
+ if self.vmag_sigma is not None:
285
+ ret += f'+/- {self.vmag_sigma:6.3f} '
286
+
287
+ if self.pm_ra is not None:
288
+ ret += ' | PM RA %.3f mas/yr ' % (self.pm_ra / MAS_TO_RAD / YEAR_TO_SEC)
289
+ if self.pm_ra_sigma:
290
+ ret += '+/- %.3f ' % (self.pm_ra_sigma / MAS_TO_RAD / YEAR_TO_SEC)
291
+
292
+ if self.pm_dec is not None:
293
+ ret += ' | PM DEC %.3f mas/yr ' % (self.pm_dec / MAS_TO_RAD / YEAR_TO_SEC)
294
+ if self.pm_dec_sigma:
295
+ ret += '+/- %.3f ' % (self.pm_dec_sigma / MAS_TO_RAD / YEAR_TO_SEC)
296
+
297
+ ret += '\n'
298
+
299
+ if self.temperature is None:
300
+ ret += 'TEMP N/A'
301
+ else:
302
+ ret += f'TEMP {self.temperature:5d}'
303
+ ret += f' | SCLASS {self.spectral_class:s}'
304
+
305
+ return ret
306
+
307
+ # This is a stupid thing to do, but it's necessary to avoid mypy from complaining
308
+ # about missing attributes. mypy ignores attributes for classes that have a
309
+ # __getattr__ method.
310
+ @no_type_check
311
+ def __getattr__(self, name: str) -> Any:
312
+ raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
313
+
314
+ def to_dict(self) -> dict[str, Any]:
315
+ """Return a dictionary containing all star attributes."""
316
+
317
+ attribs = inspect.getmembers(self, lambda a: not inspect.isroutine(a))
318
+ attribs = [a for a in attribs
319
+ if not (a[0].startswith('__') and a[0].endswith('__'))]
320
+ return dict(attribs)
321
+
322
+ def from_dict(self, d: dict[str, Any]) -> None:
323
+ """Set the attributes for this star based on the dictionary."""
324
+
325
+ for key in list(d.keys()):
326
+ setattr(self, key, d[key])
327
+
328
+ def ra_dec_with_pm(self, tdb: float) -> tuple[float, float] | tuple[None, None]:
329
+ """Return the star's RA and DEC adjusted for proper motion.
330
+
331
+ If no proper motion is available, the original RA and DEC are returned.
332
+
333
+ Parameters:
334
+ tdb: The time since the J2000 epoch in seconds.
335
+
336
+ Returns:
337
+ A tuple containing the RA and DEC adjusted for proper motion, if possible.
338
+ """
339
+
340
+ if self.ra is None or self.dec is None:
341
+ return (None, None)
342
+
343
+ if self.pm_ra is None or self.pm_dec is None:
344
+ return (self.ra, self.dec)
345
+
346
+ return (self.ra + tdb*self.pm_ra, self.dec + tdb*self.pm_dec)
347
+
348
+ @staticmethod
349
+ def temperature_from_sclass(sclass: Optional[str]) -> float | None:
350
+ """Return a star's temperature (K) given its spectral class.
351
+
352
+ Parameters:
353
+ sclass: The spectral class.
354
+
355
+ Returns:
356
+ The temperature associated with the spectral class.
357
+ """
358
+
359
+ if sclass is None:
360
+ return None
361
+ if sclass.endswith('*'): # This happens on some SPICE catalog stars
362
+ sclass = sclass[:-1]
363
+ sclass = sclass.strip().upper()
364
+ try:
365
+ return SCLASS_TO_SURFACE_TEMP[sclass]
366
+ except KeyError:
367
+ return None
368
+
369
+ @staticmethod
370
+ def bmv_from_sclass(sclass: str) -> float | None:
371
+ """Return a star's B-V color given its spectral class.
372
+
373
+ Parameters:
374
+ sclass: The spectral class.
375
+
376
+ Returns:
377
+ The B-V color associated with the spectral class.
378
+ """
379
+
380
+ if sclass[-1] == '*': # This happens on some SPICE catalog stars
381
+ sclass = sclass[:-1]
382
+ sclass = sclass.strip().upper()
383
+ try:
384
+ return SCLASS_TO_B_MINUS_V[sclass]
385
+ except KeyError:
386
+ return None
387
+
388
+ @staticmethod
389
+ def sclass_from_bv(b: float,
390
+ v: float) -> str | None:
391
+ """Return a star's spectral class given photometric B and V.
392
+
393
+ Parameters:
394
+ b: The photometric B value.
395
+ v: The photometric V value.
396
+
397
+ Returns:
398
+ The spectral class, if available.
399
+ """
400
+
401
+ bmv = b - v
402
+
403
+ best_sclass = None
404
+ best_resid = 1e38
405
+
406
+ min_bmv = 1e38
407
+ max_bmv = -1e38
408
+ for sclass, sbmv in SCLASS_TO_B_MINUS_V.items():
409
+ min_bmv = min(min_bmv, sbmv)
410
+ max_bmv = max(max_bmv, sbmv)
411
+ resid = abs(sbmv-bmv)
412
+ if resid < best_resid:
413
+ best_resid = resid
414
+ best_sclass = sclass
415
+
416
+ if min_bmv <= bmv <= max_bmv:
417
+ return best_sclass
418
+
419
+ return None
420
+
421
+
422
+ class StarCatalog:
423
+ def __init__(self) -> None:
424
+ self.debug_level = 0
425
+
426
+ def count_stars(self, **kwargs: Any) -> int:
427
+ """Count the stars that match the given search criteria."""
428
+
429
+ count = 0
430
+ for _ in self.find_stars(full_result=False, **kwargs):
431
+ count += 1
432
+ return count
433
+
434
+ def find_stars(self,
435
+ ra_min: float = 0,
436
+ ra_max: float = TWOPI,
437
+ dec_min: float = -HALFPI,
438
+ dec_max: float = HALFPI,
439
+ vmag_min: Optional[float] = None,
440
+ vmag_max: Optional[float] = None,
441
+ full_result: bool = True,
442
+ **kwargs: Any) -> Iterator[Star]:
443
+ """Yield the stars that match the given search criteria.
444
+
445
+ Parameters:
446
+ ra_min: The minimum RA.
447
+ ra_max: The maximum RA.
448
+ dec_min: The minimum DEC.
449
+ dec_max: The maximum DEC.
450
+ vmag_min: The minimum visual magnitude.
451
+ vmag_max: The maximum visual magnitude.
452
+ full_result: If True, fill in all available fields of the resulting
453
+ :class:`Star`. If False, some fields will not be filled in to save
454
+ time. This is most useful when counting stars.
455
+
456
+ Yields:
457
+ The :class:`Star` objects that meet the given constraints.
458
+ """
459
+
460
+ ra_min = np.clip(ra_min, 0., TWOPI)
461
+ ra_max = np.clip(ra_max, 0., TWOPI)
462
+ dec_min = np.clip(dec_min, -HALFPI, HALFPI)
463
+ dec_max = np.clip(dec_max, -HALFPI, HALFPI)
464
+
465
+ if ra_min > ra_max:
466
+ if dec_min > dec_max:
467
+ # Split into four searches
468
+ for star in self._find_stars(0., ra_max, -HALFPI, dec_max,
469
+ vmag_min=vmag_min, vmag_max=vmag_max,
470
+ full_result=full_result,
471
+ **kwargs):
472
+ yield star
473
+ for star in self._find_stars(ra_min, TWOPI, -HALFPI, dec_max,
474
+ vmag_min=vmag_min, vmag_max=vmag_max,
475
+ full_result=full_result,
476
+ **kwargs):
477
+ yield star
478
+ for star in self._find_stars(0., ra_max, dec_min, HALFPI,
479
+ vmag_min=vmag_min, vmag_max=vmag_max,
480
+ full_result=full_result,
481
+ **kwargs):
482
+ yield star
483
+ for star in self._find_stars(ra_min, TWOPI, dec_min, HALFPI,
484
+ vmag_min=vmag_min, vmag_max=vmag_max,
485
+ full_result=full_result,
486
+ **kwargs):
487
+ yield star
488
+ else:
489
+ # Split into two searches - RA
490
+ for star in self._find_stars(0., ra_max, dec_min, dec_max,
491
+ vmag_min=vmag_min, vmag_max=vmag_max,
492
+ full_result=full_result,
493
+ **kwargs):
494
+ yield star
495
+ for star in self._find_stars(ra_min, TWOPI, dec_min, dec_max,
496
+ vmag_min=vmag_min, vmag_max=vmag_max,
497
+ full_result=full_result,
498
+ **kwargs):
499
+ yield star
500
+ else:
501
+ if dec_min > dec_max:
502
+ # Split into two searches - DEC
503
+ for star in self._find_stars(ra_min, ra_max, -HALFPI, dec_max,
504
+ vmag_min=vmag_min, vmag_max=vmag_max,
505
+ full_result=full_result,
506
+ **kwargs):
507
+ yield star
508
+ for star in self._find_stars(ra_min, ra_max, dec_min, HALFPI,
509
+ vmag_min=vmag_min, vmag_max=vmag_max,
510
+ full_result=full_result,
511
+ **kwargs):
512
+ yield star
513
+ else:
514
+ # No need to split at all
515
+ for star in self._find_stars(ra_min, ra_max, dec_min, dec_max,
516
+ vmag_min=vmag_min, vmag_max=vmag_max,
517
+ full_result=full_result,
518
+ **kwargs):
519
+ yield star
520
+
521
+ def _find_stars(self,
522
+ ra_min: float,
523
+ ra_max: float,
524
+ dec_min: float,
525
+ dec_max: float,
526
+ vmag_min: Optional[float] = None,
527
+ vmag_max: Optional[float] = None,
528
+ full_result: bool = True,
529
+ **kwargs: Any) -> Iterator[Star]:
530
+ raise NotImplementedError