Source code for astrolore.dataset

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 format_sources(sources: list): """Format a list of sci-fi source titles into a human-readable Oxford-comma string. Converts a raw list produced by splitting the ``scifi_source`` CSV field into a grammatically correct English enumeration. Args: sources (list[str]): Sci-fi titles, e.g. ``['Star Trek', 'Star Wars', 'Dune']``. Returns: str: Formatted string. Examples: - ``[]`` → ``""`` - ``['Dune']`` → ``"Dune"`` - ``['Star Trek', 'Dune']`` → ``"Star Trek and Dune"`` - ``['Star Trek', 'Star Wars', 'Dune']`` → ``"Star Trek, Star Wars, and Dune"`` """ if not sources: return "" elif len(sources) == 1: return sources[0] elif len(sources) == 2: return f"{sources[0]} and {sources[1]}" else: return f"{', '.join(sources[:-1])}, and {sources[-1]}"
[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)