asteroidnet2 / asteroidnet /data_access /skybot_client.py
mmrech's picture
feat: v0.2 — real FITS support, TAI/UTC fix, SkyBoT, two-pass bg
41d98e2 verified
"""
AsteroidNET SkyBoT Client (data_access.skybot_client)
Queries the IMCCE SkyBoT service to find all known solar system objects
in a given sky field at a given epoch. Used both for:
1. Removing known asteroids from candidate lists (catalog_matcher)
2. Labeling archival FITS frames for ML training (training.dataset_builder)
SkyBoT covers ephemerides for all known SSOs from 1889 to 2060.
Reference: https://ssp.imcce.fr/webservices/skybot/
"""
from __future__ import annotations
import logging
from typing import Optional
import requests
from astropy.coordinates import SkyCoord
from astropy.table import Table
from astropy.time import Time
import astropy.units as u
logger = logging.getLogger(__name__)
_SKYBOT_URL = "https://ssp.imcce.fr/webservices/skybot/api/conesearch.php"
def query_skybot(
center: SkyCoord,
radius: u.Quantity,
epoch: Time,
observer: str = "500",
config: Optional[dict] = None,
) -> Table:
"""
Cone-search the SkyBoT service for known solar system objects.
Parameters
----------
center : SkyCoord
Field center.
radius : Quantity
Search radius (e.g. 30*u.arcmin).
epoch : Time
UTC observation epoch (use mid-exposure time).
observer : str
MPC observatory code. '500' = geocenter.
Use 'F51' for Pan-STARRS, '695' for Palomar.
config : dict, optional
Pipeline configuration dict.
Returns
-------
Table
Astropy Table with columns: Number, Name, RA(h), DE(deg),
Type, Mv, posunc, ErrRA, ErrDE, d, dRA, dDE, Rgeo, Rhel
Returns empty Table if no objects found or service unavailable.
"""
url = (config or {}).get("data_access", {}).get("skybot", {}).get("url", _SKYBOT_URL)
observer = (config or {}).get("data_access", {}).get("skybot", {}).get(
"default_observer", observer)
# SkyBoT expects RA in degrees, Dec in degrees, epoch as JD (UTC)
ra_deg = center.icrs.ra.deg
dec_deg = center.icrs.dec.deg
radius_deg = radius.to(u.deg).value
jd_utc = epoch.utc.jd
params = {
"EPOCH": f"{jd_utc:.6f}",
"-ra": f"{ra_deg:.6f}",
"-dec": f"{dec_deg:.6f}",
"-bd": f"{radius_deg:.4f}",
"-loc": observer,
"-mime": "votable",
"-filter": "0", # 0 = all object types
"-refsys": "EQJ2000",
}
logger.info(
"SkyBoT query: RA=%.4f Dec=%.4f r=%.2f' epoch=%s obs=%s",
ra_deg, dec_deg, radius.to(u.arcmin).value, epoch.utc.isot, observer
)
try:
resp = requests.get(url, params=params, timeout=30)
resp.raise_for_status()
if "No solar system object" in resp.text or len(resp.text) < 100:
logger.debug("SkyBoT: no known SSOs in field")
return Table()
from astropy.io.votable import parse_single_table
import io
votable = parse_single_table(io.BytesIO(resp.content))
table = votable.to_table()
logger.info("SkyBoT: found %d known SSOs in field", len(table))
return table
except requests.exceptions.RequestException as exc:
logger.warning("SkyBoT query failed (network): %s — proceeding without known-object removal", exc)
return Table()
except Exception as exc:
logger.warning("SkyBoT parse error: %s — proceeding without known-object removal", exc)
return Table()
def skybot_table_to_skycoord(table: Table) -> Optional[SkyCoord]:
"""Convert SkyBoT result table to SkyCoord array for cross-matching."""
if len(table) == 0:
return None
ra_col = "RA(h)" if "RA(h)" in table.colnames else "RA"
dec_col = "DE(deg)" if "DE(deg)" in table.colnames else "Dec"
return SkyCoord(
ra=table[ra_col],
dec=table[dec_col],
unit=(u.hourangle if "h" in ra_col else u.deg, u.deg),
frame="icrs",
)