"""Star catalog utilities for visibility constraints."""
import contextlib
from pathlib import Path
from typing import cast
import astropy.units as u # type: ignore[import-untyped]
import diskcache
from astropy.coordinates import SkyCoord # type: ignore[import-untyped]
from astroquery.vizier import Vizier # type: ignore[import-untyped]
# Column name options for different catalogs
[docs]
_RA_COLUMNS = ["_RA.icrs", "RA_ICRS", "RAJ2000", "RA(ICRS)", "_RAJ2000", "ra", "RAICRS"]
[docs]
_DEC_COLUMNS = ["_DE.icrs", "DE_ICRS", "DEJ2000", "DE(ICRS)", "_DEJ2000", "dec", "DEICRS"]
[docs]
_MAG_COLUMNS = ["Vmag", "Bmag", "mag", "V"]
# Initialize diskcache lazily
[docs]
_cache: diskcache.Cache | None = None
[docs]
def _get_cache_dir() -> Path:
"""Get the cache directory for star catalog data."""
cache_home = Path.home() / ".cache"
if not cache_home.exists():
cache_home = Path.home()
cache_dir = cache_home / "across-tools" / "star_catalogs"
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir
[docs]
def _get_cache_instance() -> diskcache.Cache:
"""Get or initialize the diskcache instance."""
global _cache
if _cache is None:
_cache = diskcache.Cache(str(_get_cache_dir()), size_limit=5e8)
return _cache
[docs]
def cache_clear() -> None:
"""Clear the diskcache cache."""
global _cache
if _cache is not None:
_cache.close()
_cache = None
[docs]
def get_bright_stars(
magnitude_limit: float = 6.0,
catalog: str = "I/239/hip_main",
max_stars: int | None = None,
) -> list[tuple[SkyCoord, float]]:
"""
Retrieve bright stars from a catalog filtered by magnitude.
This function queries the Hipparcos catalog via Vizier and returns
stars brighter than the specified magnitude limit along with their
magnitudes. Results are cached persistently on disk to minimize
network calls to astroquery.
Parameters
----------
magnitude_limit : float, optional
Magnitude limit for stars to retrieve. Stars brighter (lower magnitude)
than this value will be returned. Default is 6.0 (naked eye visible).
catalog : str, optional
Vizier catalog identifier. Default is "I/239/hip_main" (Hipparcos Main Catalog).
Other options include:
- "I/259/tyc2" (Tycho-2 Catalogue)
- "V/50/catalog" (Yale Bright Star Catalog)
max_stars : int, optional
Maximum number of stars to retrieve. If None, retrieves all stars
brighter than magnitude_limit. Default is None.
Returns
-------
list[tuple[SkyCoord, float]]
List of tuples containing (sky coordinate, magnitude) for bright stars.
Examples
--------
>>> # Get all stars brighter than magnitude 6
>>> stars = get_bright_stars(magnitude_limit=6.0)
>>> for coord, mag in stars[:5]:
... print(f"Star at {coord.ra.deg:.2f}, {coord.dec.deg:.2f} with mag {mag:.2f}")
>>> # Get brightest 100 stars
>>> bright_100 = get_bright_stars(magnitude_limit=10.0, max_stars=100)
Notes
-----
Results are cached using diskcache for persistent caching across sessions.
Disk cache location: ~/.cache/across-tools/star_catalogs/
(or ~/across-tools/star_catalogs/ on systems without ~/.cache)
The Hipparcos catalog contains ~118,000 stars. Filtering by magnitude
significantly reduces the number of stars to consider:
- mag < 3: ~200 stars
- mag < 6: ~5,000 stars
- mag < 8: ~25,000 stars
"""
# Create a cache key from function parameters
cache_key = f"stars_{magnitude_limit}_{catalog}_{max_stars}"
# Try to get from cache
try:
cached_data = _get_cache_instance().get(cache_key)
if cached_data is not None:
return cast(list[tuple[SkyCoord, float]], cached_data)
except Exception:
pass
# Query Vizier
try:
vizier = Vizier(row_limit=max_stars if max_stars is not None else -1)
result = vizier.query_constraints(catalog=catalog, Vmag=f"<{magnitude_limit}")
star_table = result[0] if result else None
except Exception:
star_table = None
# Fallback if query fails (not cached to allow retries)
if star_table is None:
return _get_fallback_bright_stars()
# Find RA, dec and magnitude column
ra_col = next((col for col in _RA_COLUMNS if col in star_table.colnames), None)
dec_col = next((col for col in _DEC_COLUMNS if col in star_table.colnames), None)
mag_col = next((col for col in _MAG_COLUMNS if col in star_table.colnames), None)
if ra_col is None or dec_col is None or mag_col is None:
return _get_fallback_bright_stars()
# Get RA/Dec with proper units
ra_data = star_table[ra_col]
dec_data = star_table[dec_col]
# Ensure RA/Dec have units (some catalogs may not include them)
if not (hasattr(ra_data, "unit") and ra_data.unit is not None):
ra_data = ra_data * u.deg
if not (hasattr(dec_data, "unit") and dec_data.unit is not None):
dec_data = dec_data * u.deg
# Extract magnitudes
magnitudes = star_table[mag_col]
# Create star data
stars = SkyCoord(ra=ra_data, dec=dec_data, frame="icrs")
star_data = [(star, float(mag)) for star, mag in zip(stars, magnitudes)]
# Cache result (if it fails, then no caching)
with contextlib.suppress(Exception):
_get_cache_instance()[cache_key] = star_data
return star_data
[docs]
def _get_fallback_bright_stars() -> list[tuple[SkyCoord, float]]:
"""
Provide a fallback list of very bright stars if catalog query fails.
This includes the 20 brightest stars in the sky, suitable for
basic bright star avoidance when network access is unavailable.
Returns
-------
list[tuple[SkyCoord, float]]
List of tuples containing (sky coordinate, magnitude) for the brightest stars (magnitude < ~1.5).
"""
return [
(SkyCoord(ra="06h45m08.9s", dec="-16d42m58.0s", frame="icrs"), -1.46), # Sirius
(SkyCoord(ra="06h23m57.1s", dec="-52d41m44.4s", frame="icrs"), -0.74), # Canopus
(SkyCoord(ra="14h39m36.5s", dec="-60d50m02.3s", frame="icrs"), -0.27), # Alpha Centauri
(SkyCoord(ra="14h15m39.7s", dec="19d10m56.7s", frame="icrs"), -0.05), # Arcturus
(SkyCoord(ra="18h36m56.3s", dec="38d47m01.3s", frame="icrs"), 0.03), # Vega
(SkyCoord(ra="05h16m41.4s", dec="45d59m52.8s", frame="icrs"), 0.08), # Capella
(SkyCoord(ra="05h14m32.3s", dec="-08d12m05.9s", frame="icrs"), 0.13), # Rigel
(SkyCoord(ra="07h39m18.1s", dec="05d13m30.0s", frame="icrs"), 0.34), # Procyon
(SkyCoord(ra="05h55m10.3s", dec="07d24m25.4s", frame="icrs"), 0.50), # Betelgeuse
(SkyCoord(ra="01h37m42.8s", dec="-57d14m12.3s", frame="icrs"), 0.46), # Achernar
(SkyCoord(ra="14h03m49.4s", dec="-60d22m22.3s", frame="icrs"), 0.61), # Hadar
(SkyCoord(ra="12h26m35.9s", dec="-63d05m56.7s", frame="icrs"), 0.77), # Acrux
(SkyCoord(ra="20h41m25.9s", dec="45d16m49.3s", frame="icrs"), 0.77), # Altair
(SkyCoord(ra="16h29m24.5s", dec="-26d25m55.2s", frame="icrs"), 0.85), # Aldebaran
(SkyCoord(ra="13h25m11.6s", dec="-11d09m40.8s", frame="icrs"), 0.98), # Spica
(SkyCoord(ra="08h09m32.0s", dec="-47d20m11.7s", frame="icrs"), 1.06), # Antares
(SkyCoord(ra="22h57m39.0s", dec="-29d37m20.0s", frame="icrs"), 1.16), # Fomalhaut
(SkyCoord(ra="07h24m05.7s", dec="-28d58m19.5s", frame="icrs"), 1.14), # Pollux
(SkyCoord(ra="08h44m42.2s", dec="-59d30m34.1s", frame="icrs"), 1.25), # Deneb
(SkyCoord(ra="12h15m08.7s", dec="-17d32m30.9s", frame="icrs"), 1.25), # Mimosa
]