"""Functions for interacting with the Horizons API."""
import jax
jax.config.update("jax_enable_x64", True)
import io
from contextlib import contextmanager
from typing import NamedTuple
import astropy.units as u
import jax.numpy as jnp
import pandas as pd
import requests
from astropy.time import Time
from astroquery.jplhorizons import Horizons
from jorbit import Ephemeris
from jorbit.data.observatory_codes import OBSERVATORY_CODES
from jorbit.utils.mpc import packed_to_unpacked_designation
[docs]
class HorizonsQueryConfig(NamedTuple):
"""Configuration for Horizons API queries."""
HORIZONS_API_URL = "https://ssd.jpl.nasa.gov/api/horizons_file.api"
# hard limit from the Horizons api
MAX_TIMESTEPS = 10_000
# kinda arbitrary, have gotten it to work with ~50 but seems like it can be finicky
ASTROQUERY_MAX_TIMESTEPS = 25
VECTOR_COLUMNS = [
"JD_TDB",
"Cal",
"x",
"y",
"z",
"vx",
"vy",
"vz",
"LT",
"RG",
"RR",
"_",
]
ASTROMETRY_COLUMNS = [
"JD_UTC",
"twilight_flag",
"moon_flag",
"RA",
"DEC",
"V",
"S-brt",
"RA_3sigma",
"DEC_3sigma",
"SMAA_3sigma",
"SMIA_3sigma",
"Theta_3sigma",
"Area_3sigma",
"_",
]
[docs]
def horizons_query_string(
target: str, center: str, query_type: str, times: Time, skip_daylight: bool = False
) -> str:
"""Constructs the query string for the Horizons API.
Args:
target (str):
The target object identifier.
center (str):
The center object identifier.
query_type (str):
The type of query, either 'VECTOR' or 'OBSERVER'.
times (Time):
The times for the query. Note it just needs to be an Astropy Time object-
we'll handle the different tdb/utc conversions internally.
skip_daylight (bool):
Whether to skip daylight in the query.
Returns:
str:
The constructed query string.
"""
# now giving option to disable astroquery for small searches
# assert len(times) > HorizonsQueryConfig.ASTROQUERY_MAX_TIMESTEPS
# this is deeply, fundamentally upsetting
# if you pass a designation to Horizons without calling it "DES", it sometimes gives
# you the wrong object. But, you can't call "DES" on numbered objects, even in
# packed form?? This is an attempted workaround that I worry will come back to bite
# me eventually.
# if it's 7 characters, assume its a packed form of a provisional designation
if len(target) == 7:
target = packed_to_unpacked_designation(target)
c = f'COMMAND= "DES={target};"'
else:
target = packed_to_unpacked_designation(target)
c = f'COMMAND= "{target};"'
if len(times) > HorizonsQueryConfig.MAX_TIMESTEPS:
raise ValueError(
f"Horizons batch API can only accept less than {HorizonsQueryConfig.MAX_TIMESTEPS} timesteps"
)
lines = [
"!$$SOF",
c,
"OBJ_DATA='NO'",
"MAKE_EPHEM='YES'",
f"CENTER='{center}'",
"REF_PLANE='FRAME'",
"CSV_FORMAT='YES'",
"OUT_UNITS='AU-D'",
"CAL_FORMAT='JD'",
"TLIST_TYPE='JD'",
]
if query_type == "VECTOR":
lines.append("TABLE_TYPE='VECTOR'")
elif query_type == "OBSERVER":
lines.extend(
[
"TABLE_TYPE='OBSERVER'",
"QUANTITIES='1,9,36,37'",
"ANG_FORMAT='DEG'",
"EXTRA_PREC = 'YES'",
]
)
if skip_daylight:
lines.append("SKIP_DAYLT = 'YES'")
lines.append("TLIST=")
for t in times:
if query_type == "VECTOR":
time_value = t.tdb.jd if isinstance(t, Time) else t
elif query_type == "OBSERVER":
time_value = t.utc.jd if isinstance(t, Time) else t
lines.append(f"'{time_value}'")
query = "\n".join(lines)
return query
[docs]
@contextmanager
def horizons_query_context(query_string: str) -> io.StringIO:
"""Creates and manages the query content in memory."""
query = io.StringIO(query_string)
try:
yield query
finally:
query.close()
[docs]
def parse_horizons_response(
response_text: str, columns: list[str], skip_empty: bool = False
) -> pd.DataFrame:
"""Parses the Horizons API response into a DataFrame.
Args:
response_text (str):
The response text from the Horizons API.
columns (list[str]):
The column names for the DataFrame.
skip_empty (bool):
Whether to skip empty lines in the response.
Returns:
pd.DataFrame:
The parsed DataFrame.
"""
lines = response_text.split("\n")
try:
start = lines.index("$$SOE")
end = lines.index("$$EOE")
if skip_empty:
cleaned = [
line
for line in lines[start + 1 : end]
if line and "Daylight Cut-off Requested" not in line
]
else:
cleaned = lines[start + 1 : end]
df = pd.read_csv(io.StringIO("\n".join(cleaned)), header=None, names=columns)
df = df.drop(columns="_")
if "twilight_flag" in df.columns:
df = df.drop(columns="twilight_flag")
if "moon_flag" in df.columns:
df = df.drop(columns="moon_flag")
if "S-brt" in df.columns:
df = df.drop(columns="S-brt")
return df
except ValueError as e:
raise ValueError("Failed to parse Horizons response: invalid format") from e
[docs]
def make_horizons_request(query_content: io.StringIO) -> str:
"""Makes the HTTP request to Horizons API.
Args:
query_content (io.StringIO):
The query content to send in the request.
Returns:
str:
The response text from the Horizons API.
Raises:
ValueError:
If the request fails.
"""
try:
response = requests.post(
HorizonsQueryConfig.HORIZONS_API_URL,
data={"format": "text"},
files={"input": query_content},
)
response.raise_for_status()
return response.text
except requests.RequestException as e:
raise ValueError(f"Failed to query Horizons API: {e!s}") from e
[docs]
def horizons_bulk_vector_query(
target: str,
center: str,
times: Time,
disable_astroquery: bool = False,
) -> pd.DataFrame:
"""The main query function for our retrieval of vectors from the Horizons API.
This function creates the appropriate query string, executes the query, and parses
the response into a DataFrame. If we're requesting a small number of timesteps,
it'll use astroquery to retrieve the data, which allows for easy caching. However,
if we're requesting > 50 unique timesteps, astroquery will fail, so we instead fall
back to a manual API query. These results will not be cached.
Note: The Horizons API has a hard limit of 10,000 timesteps per query.
Args:
target (str):
The target object identifier. Must be a packed MPC designation with length
5 for numbered objects or 7 for provisional designations.
center (str):
The center object identifier.
times (Time):
The times for the query. Note it just needs to be an Astropy Time object-
we'll handle the different tdb/utc conversions internally.
disable_astroquery (bool):
Whether to disable the astroquery default for small searches.
Returns:
pd.DataFrame:
A pandas DataFrame containing the vector data.
"""
if isinstance(times.jd, float):
times = [times]
if (len(times) < HorizonsQueryConfig.ASTROQUERY_MAX_TIMESTEPS) and (
not disable_astroquery
):
if len(target) == 7:
target = packed_to_unpacked_designation(target)
idtype = "designation"
else:
target = packed_to_unpacked_designation(target)
idtype = "smallbody"
# note that astrometry queries use utc, vector use tdb...
horizons_obj = Horizons(
id=target, location=center, epochs=[t.tdb.jd for t in times], id_type=idtype
)
vec_table = horizons_obj.vectors(refplane="earth")
vec_table = vec_table[
[
"datetime_jd",
"x",
"y",
"z",
"vx",
"vy",
"vz",
"lighttime",
"range",
"range_rate",
]
].to_pandas()
vec_table.rename(
columns={
"datetime_jd": "JDTDB",
"lighttime": "LT",
"range": "RG",
"range_rate": "RR",
},
inplace=True,
)
return vec_table
try:
# Build query
query = horizons_query_string(target, center, "VECTOR", times)
# Execute query
with horizons_query_context(query) as query_content:
response_text = make_horizons_request(query_content)
return parse_horizons_response(
response_text, HorizonsQueryConfig.VECTOR_COLUMNS
)
except Exception as e:
raise ValueError(f"Vector query failed: {e!s}") from e
[docs]
def horizons_bulk_astrometry_query(
target: str,
center: str,
times: Time,
skip_daylight: bool = False,
disable_astroquery: bool = False,
) -> pd.DataFrame:
"""The main query function for our retrieval of astrometry from the Horizons API.
This function creates the appropriate query string, executes the query, and parses
the response into a DataFrame. If we're requesting a small number of timesteps,
it'll use astroquery to retrieve the data, which allows for easy caching. However,
if we're requesting > 50 unique timesteps, astroquery will fail, so we instead fall
back to a manual API query. These results will not be cached.
Note: The Horizons API has a hard limit of 10,000 timesteps per query.
Args:
target (str):
The target object identifier. Must be a packed MPC designation with length
5 for numbered objects or 7 for provisional designations.
center (str):
The center object identifier.
times (Time):
The times for the query. Note it just needs to be an Astropy Time object-
we'll handle the different tdb/utc conversions internally.
skip_daylight (bool):
Whether to skip daylight in the query.
disable_astroquery (bool):
Whether to disable the astroquery default for small searches.
Returns:
pd.DataFrame:
A pandas DataFrame containing the astrometry data.
"""
if isinstance(times.jd, float):
times = [times]
if (len(times) < HorizonsQueryConfig.ASTROQUERY_MAX_TIMESTEPS) and (
not disable_astroquery
):
if len(target) == 7:
target = packed_to_unpacked_designation(target)
idtype = "designation"
else:
target = packed_to_unpacked_designation(target)
idtype = "smallbody"
# note that astrometry queries use utc, vector use tdb...
horizons_obj = Horizons(
id=target, location=center, epochs=[t.utc.jd for t in times], id_type=idtype
)
horizons_table = horizons_obj.ephemerides(
quantities="1,9,36,37", extra_precision=True
)
horizons_table = horizons_table[
[
"datetime_jd",
"RA",
"DEC",
"V",
"RA_3sigma",
"DEC_3sigma",
"SMAA_3sigma",
"SMIA_3sigma",
"Theta_3sigma",
"Area_3sigma",
]
].to_pandas()
horizons_table.rename(
columns={
"datetime_jd": "JD_UTC",
},
inplace=True,
)
return horizons_table
try:
# Build query
query = horizons_query_string(
target, center, "OBSERVER", times, skip_daylight=skip_daylight
)
# Execute query using StringIO
with horizons_query_context(query) as query_content:
response_text = make_horizons_request(query_content)
data = parse_horizons_response(
response_text, HorizonsQueryConfig.ASTROMETRY_COLUMNS, skip_empty=True
)
return data
except Exception as e:
raise ValueError(f"Astrometry query failed: {e!s}") from e
[docs]
def get_observer_positions(
times: Time, observatories: str | list[str], de_ephemeris_version: str
) -> jnp.ndarray:
"""A wrapper to retrieve the barycentric positions of an observer from the Horizons API.
Args:
times (Time):
The times for the query. Can be a single Time, or a Time object with length
> 1. If length < 50, positions will be retrieved using astroquery, otherwise
a manual API query will be used. Note that length must be < 10,000.
observatories (str | list[str]):
The observatory name for the query. If '@' is included in the query, it's
assumed to be a Horizons-interpretable code. Otherwise it will be compared
to the list of observatory names in the jorbit.data.observatory_codes module
and mapped to its appropriate code.
de_ephemeris_version (str):
The DE ephemeris version to use for the query. This is something of a hack-
Horizons is using something like DE440 by default, so if you want the
observer's position while assuming a different ephemeris (e.g. you're
pretending the Earth is in a different location than Horizons thinks it is),
you need to correct for the shift. This will apply a translation to align
the Earth-Moon barycenter between the requested ephemeris and DE440; it will
not perform any rotations or higher-order corrections. Accepts '440' or
'430'.
Returns:
jnp.ndarray:
The barycentric positions of the observer in AU.
"""
if isinstance(times.jd, float):
times = [times]
if isinstance(observatories, str):
observatories = [observatories]
# allow either a single observatory, or a list of observatories
# w/ the same length as times
if len(observatories) == 1:
observatories = observatories * len(times)
assert len(times) == len(observatories)
# just to standardize:
# the vector/astrometry queries automatically convert to utc/tdb as appropriate
times = Time([t.utc.jd for t in times], format="jd", scale="utc")
sort_indices = jnp.argsort(times.jd)
times = times[sort_indices]
observatories = [observatories[i] for i in sort_indices]
emb_from_ssb = horizons_bulk_vector_query("3", "500@0", times)
emb_from_ssb = jnp.array(emb_from_ssb[["x", "y", "z"]].values)
_times = []
emb_from_observer_all = jnp.empty((0, 3))
for obs in set(observatories):
idxs = [i for i, x in enumerate(observatories) if x == obs]
if "@" not in obs:
if obs.lower() in OBSERVATORY_CODES:
obs = OBSERVATORY_CODES[obs.lower()]
else:
raise ValueError(
f"Observer location '{obs}' is not a recognized observatory. Please"
" refer to"
" https://minorplanetcenter.net/iau/lists/ObsCodesF.html"
)
_emb_from_observer = horizons_bulk_vector_query("3", obs, times[idxs])
_emb_from_observer = jnp.array(_emb_from_observer[["x", "y", "z"]].values)
emb_from_observer_all = jnp.concatenate(
[emb_from_observer_all, _emb_from_observer]
)
_times.extend(times[idxs])
_times = jnp.array([t.tdb.jd for t in _times])
emb_from_observer = jnp.array(emb_from_observer_all)[jnp.argsort(_times)]
inverse_indices = jnp.argsort(sort_indices)
positions = emb_from_ssb[inverse_indices] - emb_from_observer[inverse_indices]
if de_ephemeris_version != "440":
eph_440 = Ephemeris(
ssos="default planets",
earliest_time=min(times) - 30 * u.day,
latest_time=max(times) + 30 * u.day,
de_ephemeris_version="440",
)
eph_430 = Ephemeris(
ssos="default planets",
earliest_time=min(times) - 30 * u.day,
latest_time=max(times) + 30 * u.day,
de_ephemeris_version="430",
)
earth_440 = eph_440.state(times)["earth"]
earth_430 = eph_430.state(times)["earth"]
delta_x = (earth_430["x"] - earth_440["x"]).value
positions += delta_x
return positions