import pandas as pd
from astropy.coordinates import SkyCoord
import astropy.units as u
import webbrowser
import os
import numpy as np
import matplotlib.pyplot as plt
from importlib.resources import files
import webview
[docs]
class astrolore_dataset():
"""Dataset of real astronomical objects referenced in science fiction, with methods to query it.
The bundled CSV (``astrolore/data/scifi_dataset.csv``) contains entries for stars, galaxies,
nebulae, and other astrophysical objects that appear in science fiction works. Each row stores
the object's name, ICRS RA/DEC coordinates (in degrees), object type, the sci-fi titles that
reference it, and a short lore description.
Typical usage::
ds = astrolore_dataset()
closest = ds.find_closest_object(name="Betelgeuse")
print(ds.output_lore(closest))
fig, ax = ds.get_catalog_map()
"""
def __init__(self):
"""Load the bundled sci-fi catalog CSV into a pandas DataFrame.
The CSV is read from the ``astrolore.data`` package resource directory so the path
is always valid regardless of where the package is installed.
Attributes set:
scifi_dataframe (pandas.DataFrame): The full sci-fi object catalog with columns
``name``, ``ra`` (degrees), ``dec`` (degrees), ``object_type``,
``scifi_source``, and ``lore``.
"""
csv_path = files("astrolore.data").joinpath("scifi_dataset.csv")
self.scifi_dataframe = pd.read_csv(csv_path)
[docs]
@staticmethod
def get_coords_from_name(name):
"""Resolve an astronomical object name to sky coordinates via CDS Sesame.
Delegates to ``astropy.coordinates.SkyCoord.from_name``, which queries the
Simbad/NED name resolver over the network.
Args:
name (str): Common or catalogue name of the object, e.g. ``"Andromeda"``
or ``"NGC 224"``.
Returns:
astropy.coordinates.SkyCoord: ICRS coordinates of the resolved object.
Raises:
astropy.coordinates.name_resolve.NameResolveError: If the name cannot be
found in any of the queried catalogues.
"""
return SkyCoord.from_name(name)
[docs]
def name_of_object(self, object):
"""Return the ``name`` field of a catalog row.
Args:
object (pandas.Series): A single row from ``scifi_dataframe``, as returned
by ``find_closest_object``.
Returns:
str: The ``name`` column value for that row.
Raises:
ValueError: If *object* is not a ``pandas.Series``.
"""
if isinstance(object, pd.Series):
return object["name"]
else:
raise ValueError
[docs]
def index_of_object(self, object):
"""Return the integer position of a catalog object within ``scifi_dataframe``.
Args:
object (str): The name of the object to look up. Must match exactly one
entry in the ``name`` column of ``scifi_dataframe``.
Returns:
int: Zero-based index of the object in ``scifi_dataframe``.
Raises:
NotImplementedError: If *object* is a ``pandas.Series`` (lookup by Series
is not yet implemented).
ValueError: If *object* is neither a ``str`` nor a ``pandas.Series``.
"""
if isinstance(object, str):
names = list(self.scifi_dataframe["name"])
idx = names.index(object)
elif isinstance(object, pd.Series):
raise NotImplementedError
else:
raise ValueError
return idx
[docs]
def find_closest_object(self, name: str = None, coords=None):
"""Find the catalog object with the smallest angular separation from a given position.
Exactly one of *name* or *coords* must be provided. When *name* is given, the
position is resolved via the CDS Sesame name resolver (requires a network
connection). When *coords* is given, the position is parsed directly without any
network call.
The angular separation between the resolved position and every row in
``scifi_dataframe`` is computed using ``astropy.coordinates.SkyCoord.separation``
and stored in a temporary ``ang_sep`` column (degrees). The row with the minimum
separation is returned.
Side effects:
Sets ``self.name``, ``self.name_flag``, and ``self.user_coords`` on the
instance so that ``output_lore`` and ``get_catalog_map`` can reference them.
Args:
name (str, optional): Common or catalogue name of the target object, e.g.
``"Arcturus"`` or ``"M31"``. Defaults to ``None``.
coords (tuple[str, str], optional): Sky position as a ``(ra, dec)`` pair of
sexagesimal strings in the format ``('HHhMMmSSs', 'DDdMMmSSs')``, e.g.
``('10h8m22.3s', '11d58m1.95s')``. The frame is assumed to be ICRS.
Defaults to ``None``.
Returns:
pandas.Series: The row from ``scifi_dataframe`` that is closest on the sky
to the input position, including the computed ``ang_sep`` value in degrees.
Raises:
astropy.coordinates.name_resolve.NameResolveError: If *name* is given but
cannot be resolved.
ValueError: If the coordinate strings in *coords* are malformed.
"""
self.name_flag = name
if name==None:
name='Coordinates'
if name[0].isupper():
self.name = name
else:
self.name = name.capitalize()
if coords is None:
self.user_coords = self.get_coords_from_name(name)
else:
ra, dec = coords
self.user_coords = SkyCoord(ra, dec, frame='icrs', unit=(u.hourangle, u.deg))
#name = input('''Welcome to AstroLoreBot v1.0!\nGiven an astrophysical object of your choice, I output the nearest object on the sky referenced in sci-fi.\nWhenever you're ready, name your object:\n>>> ''')
self.scifi_dataframe['ang_sep'] = SkyCoord(ra=self.scifi_dataframe.ra.values, dec=self.scifi_dataframe.dec.values).separation(self.user_coords).value
close_object = self.scifi_dataframe.loc[self.scifi_dataframe.ang_sep.idxmin()]
return close_object
[docs]
def output_lore(self, close_object: pd.Series):
"""Build a human-readable lore string for the closest sci-fi object.
Produces one of two message styles:
* **Exact match** (``ang_sep < 0.01°``): Announces that the user's own object
is referenced in science fiction and quotes its lore text directly.
* **Nearest match**: Reports the angular distance to the closest catalog object
and quotes its sci-fi references and lore.
Side effects:
Sets ``self.close_object`` on the instance so that ``get_catalog_map`` can
reference it without re-running the search.
Args:
close_object (pandas.Series): The catalog row returned by
``find_closest_object``. Must contain the fields ``ang_sep``,
``scifi_source``, ``name``, ``object_type``, and ``lore``.
Returns:
str: Formatted multi-line string ready to display in the GUI or print to
the terminal.
"""
# Convert string to list of sources
raw = close_object.scifi_source.strip("()")
sources = [s.strip() for s in raw.split(",")]
formatted_sources = self.format_sources(sources)
# Threshold for "exact match"
sep = round(close_object.ang_sep, 5)
if self.name_flag is None:
text = 'your coordinates'
else:
text = self.name
if sep < 0.01:
output = (
f"\nSearching around {text}...\n"
f"Your chosen object is referenced in science fiction!\n"
f"\nThe {close_object['name']} {close_object.object_type} appears/is referenced in {formatted_sources}. Here's some lore about {close_object['name']}:\n"
f"\n{close_object.lore}\n"
)
else:
output = (
f"\nSearching around {text}...\n"
f"The nearest object referenced in sci-fi is {sep} degrees away — "
f"The {close_object['name']} {close_object.object_type}. "
f"Here's some lore about {close_object['name']}:\n"
f"\nThe {close_object['name']} {close_object.object_type} appears/is referenced in {formatted_sources}. {close_object.lore}"
)
self.close_object = close_object
return output
[docs]
def init_catalog_map(self, closest_object: pd.Series):
"""Pre-compute plotting coordinates for every catalog object and for the closest match.
Converts all RA/DEC values in ``scifi_dataframe`` from degrees to the radian
convention used by matplotlib's Aitoff projection (RA negated and remapped to
[-π, π] so that east is to the left, matching the standard celestial orientation).
Args:
closest_object (pandas.Series): The catalog row for the nearest sci-fi object,
as returned by ``find_closest_object``.
Returns:
tuple:
- **ra_rad** (pandas.DataFrame): Plotting RA values (radians, Aitoff convention)
for all catalog objects.
- **dec_rad** (pandas.DataFrame): Plotting DEC values (radians) for all
catalog objects.
- **closest_ra_rad** (float): Plotting RA (radians) for *closest_object*.
- **closest_dec_rad** (float): Plotting DEC (radians) for *closest_object*.
"""
# TODO: Simplify this function (if time)
coords = SkyCoord(ra=self.scifi_dataframe.ra.values, dec=self.scifi_dataframe.dec.values)
ra_rad = pd.DataFrame(np.radians(coords.ra.value))
ra_rad = np.remainder(ra_rad + 2*np.pi, 2*np.pi)
ra_rad[ra_rad > np.pi] -= 2*np.pi
ra_rad = -ra_rad
dec_rad = pd.DataFrame(np.radians(coords.dec.value))
closest_coords = SkyCoord(ra=closest_object.ra, dec=closest_object.dec)
closest_ra_rad, closest_dec_rad = self.convert_to_plotting_rad(closest_coords)
return ra_rad, dec_rad, closest_ra_rad, closest_dec_rad
[docs]
def convert_to_plotting_rad(self, coords_in_deg):
"""Convert a single ``SkyCoord`` to matplotlib Aitoff-projection radian convention.
The standard RA→x mapping for an Aitoff projection used by matplotlib negates RA
so that east appears on the left (as seen on the sky), then wraps the result into
[-π, π]. This helper encapsulates that transformation for a single coordinate.
Args:
coords_in_deg (astropy.coordinates.SkyCoord): The sky position to convert.
The ``.ra`` and ``.dec`` attributes are read in degrees.
Returns:
tuple[float, float]:
- **ra_rad** (float): RA in radians, remapped to [-π, π] with east-left
orientation.
- **dec_rad** (float): DEC in radians.
"""
closest_ra_rad = np.radians(coords_in_deg.ra.value)
closest_ra_rad = np.remainder(closest_ra_rad + 2*np.pi, 2*np.pi)
closest_ra_rad -= 2 * np.pi if closest_ra_rad > np.pi else 0
closest_ra_rad = -closest_ra_rad
closest_dec_rad = np.radians(coords_in_deg.dec.value)
return closest_ra_rad, closest_dec_rad
[docs]
def get_catalog_map(self):
"""Generate a full-sky Aitoff projection map showing the search results.
Plots three layers on a dark background:
* **White stars** — all objects in the sci-fi catalog.
* **Gold star** — the nearest catalog object to the user's input.
* **Red star** — the user's input position.
A dashed white arrow connects the user's position to the nearest catalog object
(omitted when both points are at the same sky location). Labels in gold and white
identify the two highlighted points.
Must be called *after* both ``find_closest_object`` and ``output_lore``, as it
reads ``self.user_coords``, ``self.close_object``, and ``self.name`` from instance
state set by those methods.
Returns:
tuple[matplotlib.figure.Figure, matplotlib.axes.Axes]: The figure and axes
objects. The figure is closed before returning (``plt.close(fig)``), so it
can safely be passed to ``FigureCanvasTkAgg`` without being displayed in a
separate matplotlib window.
"""
user_coords = self.user_coords
user_ra_rad, user_dec_rad = self.convert_to_plotting_rad(user_coords)
closest_object = self.close_object
closest_name = self.name_of_object(closest_object)
ra_rad, dec_rad, closest_ra_rad, closest_dec_rad = self.init_catalog_map(closest_object)
plt.rcParams.update({'mathtext.default': 'regular',
'font.family': 'Times'})
fig, ax = plt.subplots(subplot_kw={'projection': 'aitoff'}, figsize=(26,9))
fig.patch.set_facecolor('black') # Background of the figure
ax.set_facecolor('black') # Background of the plot area
ax.scatter(ra_rad, dec_rad, c='white', marker='*', s=40)
ax.scatter(closest_ra_rad, closest_dec_rad, c='gold', marker='*', s=90)
ax.scatter(user_ra_rad, user_dec_rad, c="red", marker="*", s=150)
ra_hour_ticks_deg = np.arange(0, 360, 30) # 0h to 23h
ra_hour_ticks_rad = -np.radians(np.remainder(ra_hour_ticks_deg, 360)) # Negate!
ra_hour_ticks_rad = np.remainder(ra_hour_ticks_rad + 2*np.pi, 2*np.pi)
ra_hour_ticks_rad[ra_hour_ticks_rad > np.pi] -= 2*np.pi
ra_hour_labels = [f'{int((deg / 15) % 24)}h' for deg in ra_hour_ticks_deg]
ax.set_xticks(ra_hour_ticks_rad)
ax.set_xticklabels(ra_hour_labels, fontsize=12, color='white')
dec_ticks_rad = ax.get_yticks()
dec_ticks_deg = np.degrees(dec_ticks_rad)
dec_tick_labels = [f"{int(np.round(deg))}°" for deg in dec_ticks_deg]
ax.set_yticks(dec_ticks_rad)
ax.set_yticklabels(dec_tick_labels, color='white', fontsize=10)
ax.spines['geo'].set_edgecolor('white')
ax.spines['geo'].set_linewidth(1)
ax.set_xlabel(r'$RA$', fontsize=14, color='white')
ax.set_ylabel(r'$DEC$', fontsize=14, color='white')
ax.xaxis.set_tick_params(labelsize=12, color='white')
ax.yaxis.set_tick_params(labelsize=12, color='white')
if closest_ra_rad != user_ra_rad and \
closest_dec_rad != user_dec_rad:
ax.annotate(
'', # No text
xy=(closest_ra_rad, closest_dec_rad), # Arrowhead (end) point
xytext=(user_ra_rad, user_dec_rad), # Start point
arrowprops=dict(
arrowstyle='-|>', # Style: simple arrow
color='white', # Arrow color
lw=1, # Line width
linestyle='--',
shrinkA=10,
shrinkB=5
), zorder=-9999
)
# text for the closest object in the dataset
ax.text(closest_ra_rad, closest_dec_rad-.06,
closest_name, ha='center', va='top', color='gold',
fontsize=10, bbox=dict(facecolor='gainsboro', alpha=0.5))
# text for the object provided by the user
if self.name is None:
name_print = self.user_coords
else:
name_print = self.name
ax.text(user_ra_rad, user_dec_rad-.06,
name_print, ha='center', va='top', color='white',
fontsize=10, bbox=dict(facecolor='gainsboro', alpha=.5))
ax.grid(True, alpha=.6, color='white', linewidth=1)
plt.close(fig)
return fig, ax
# def load_aladin(self):
# """Launch Aladin window to observe nearest sci-fi source"""
# if_vis = input('\nDo you want to observe the source in the real world?\n>>> ').strip().lower()
# if if_vis in ('yes', 'y'):
# coord_close_star = SkyCoord(ra=self.close_object.ra, dec=self.close_object.dec)
# url = f"https://aladin.u-strasbg.fr/AladinLite/?target={coord_close_star.ra.deg}%20{coord_close_star.dec.deg}&fov=1.5"
# webbrowser.open(url)