Repository: astropenguin/azely

Issue:
Update object module

Discussion:

Patch:
diff --git a/azely/azel.py b/azely/azel.py
index ee3dc58..2331be2 100644
--- a/azely/azel.py
+++ b/azely/azel.py
@@ -1,49 +1,3 @@
-"""Azely's azel module (high-level API).
-
-This module mainly provides ``compute`` function as a high-level API for users
-which computes azimuth/elevation of an astronomical object under given conditions.
-
-The ``compute`` function (1) gets object, location, and time information, (2) computes
-az/el and LST (local sidereal time), and (3) returns them as a pandas DataFrame.
-
-Object information can be obtained either online (CDS) or offline (an user-defined
-TOML file) by query (e.g., ``'NGC1068'`` or ``'Sun'``). Location information can be
-obtained either online (IP address or OpenStreetMap) or offline (an user-defined
-TOML file) by query (e.g., ``'Tokyo'`` or ``'ALMA AOS'``). Time information can be
-computed from either formatted (e.g., ``'2020-01-01'``) and natural language-like
-query (e.g., ``'Jan 1st 2020'``). See docstrings of ``get_[object|location|time]``
-functions for more detailed query options.
-
-There are two different locations to be used for a computation:
-(1) ``site``: location where az/el of an object is computed.
-(2) ``view``: location where time information (timezone) is considered.
-
-Examples:
-    To compute daily az/el of NGC1068 at ALMA AOS::
-
-        >>> df = azely.compute('NGC1068', 'ALMA AOS', '2020-02-01')
-
-    To compute the same object and location but view from Japan::
-
-        >>> df = azely.compute('NGC1068', 'ALMA AOS', '2020-02-01', view='Tokyo')
-
-    To compute az/el of Sun at noon during an year at Tokyo::
-
-        >>> df = azely.compute('Sun', 'Tokyo', '1/1 12:00 to 12/31 12:00', freq='1D')
-
-As DataFrame has ``plot`` method for matplotlib, plotting the result is so easy::
-
-    >>> df.el.plot() # plot elevation
-
-If users want to use LST instead of time information, use ``in_lst`` property::
-
-    >>> df.in_lst.el.plot()
-
-In order to use LST values as an index of DataFrame, LST has pseudo dates which
-start from ``1970-01-01``. Please ignore them or hide them by using matplotlib's
-DateFormatter when you plot the result.
-
-"""
 __all__ = ["AzEl", "compute"]
 
 
@@ -172,7 +126,7 @@ def compute(
             >>> df = azely.compute('Sun', 'Tokyo', '1/1 12:00 to 12/31 12:00', freq='1D')
 
     """  # noqa: E501
-    object_ = get_object(object, frame, timeout)
+    object_ = get_object(object, frame=frame, timeout=timeout)
     site_ = get_location(site, timeout=timeout)
     time_ = get_time(time, view or site, freq, dayfirst, yearfirst, timeout)
 
@@ -198,7 +152,7 @@ def _compute(object: Object, site: Location, time: Time) -> AzEl:
         AzelyError: Raised if one of mid-level APIs fails to get any information.
 
     """
-    obstime = time.to_obstime(site.earth_location)
+    obstime = time.to_obstime(site.to_earthlocation())
     skycoord = object.to_skycoord(obstime)
 
     az = skycoord.altaz.az
diff --git a/azely/consts.py b/azely/consts.py
index 3de24cb..3beab4f 100644
--- a/azely/consts.py
+++ b/azely/consts.py
@@ -10,15 +10,17 @@ __all__ = [
     "AZELY_CONFIG",
     "AZELY_OBJECT",
     "AZELY_LOCATION",
+    "SOLAR_FRAME",
+    "SOLAR_OBJECTS",
+    "HERE",
+    "NOW",
+    "TODAY",
     "DAYFIRST",
     "FRAME",
     "FREQ",
-    "HERE",
-    "NOW",
     "SITE",
     "TIME",
     "TIMEOUT",
-    "TODAY",
     "VIEW",
     "YEARFIRST",
 ]
@@ -27,10 +29,11 @@ __all__ = [
 # standard library
 from os import getenv
 from pathlib import Path
-from typing import TypeVar
+from typing import Tuple, TypeVar
 
 
 # dependencies
+from astropy.coordinates import solar_system_ephemeris as solar
 from tomlkit import load
 
 
@@ -74,16 +77,26 @@ AZELY_OBJECT = ensure(AZELY_DIR / "objects.toml")
 AZELY_LOCATION = ensure(AZELY_DIR / "locations.toml")
 
 
-# default values for public functions
+# special values for the solar system ephemeris
+SOLAR_FRAME = "solar"
+"""Special frame for objects in the solar system."""
+
+SOLAR_OBJECTS: Tuple[str, ...] = tuple(solar.bodies)  # type: ignore
+"""List of objects in the solar system."""
+
+
+# special values for function arguments
 HERE = "here"
-"""Special value for getting current location information."""
+"""Special query for getting current location information."""
 
 NOW = "now"
-"""Special value for getting current time information."""
+"""Special query for getting current time information."""
 
 TODAY = "today"
-"""Special value for getting today's time information."""
+"""Special query for getting today's time information."""
+
 
+# default values for public functions
 DAYFIRST = getval(AZELY_CONFIG, "defaults.dayfirst", False)
 """Default value for the ``dayfirst`` argument."""
 
diff --git a/azely/location.py b/azely/location.py
index 023e2e8..d9bd5d9 100644
--- a/azely/location.py
+++ b/azely/location.py
@@ -54,9 +54,8 @@ class Location:
 
         return timezone(str(response))
 
-    @property
-    def earth_location(self) -> EarthLocation:
-        """Location information in Astropy."""
+    def to_earthlocation(self) -> EarthLocation:
+        """Convert it to an EarthLocation object."""
         return EarthLocation(
             lon=Longitude(self.longitude),
             lat=Latitude(self.latitude),
diff --git a/azely/object.py b/azely/object.py
index b1b98ea..02ecc55 100644
--- a/azely/object.py
+++ b/azely/object.py
@@ -1,163 +1,77 @@
-"""Azely's object module (mid-level API).
-
-This module mainly provides ``Object`` class for information of an astronomical
-object (object information, hereafter) and ``get_object`` function to search for
-object information as an instance of ``Object`` class.
-
-The ``Object`` class is defined as
-``Object(name: str, frame: str, longitude: str, latitude: str)``,
-where frame is a name of equatorial coordinates (e.g., icrs) and lon/lat are values
-of coordinates which must be written with units like 02h42m40.771s/-00d00m47.84s.
-
-
-The ``get_object`` function acquires object information from:
-(1) Data from CDS (by default). Internet connection is required.
-(2) User-defined object information written in a TOML file.
-
-In the case of (1), obtained object information is cached in a special
-TOML file (``~/.config/azely/objects.toml``) for an offline use.
-
-In the case of (2), users can define object information in a TOML file
-(e.g., ``user.toml``) which should be put in a current directory or in the
-Azely's config directory (``~/.config/azely``). Object information must be
-defined as a table in the TOML file like::
-
-    # user.toml
-
-    [GC]
-    name = "Galactic center"
-    frame = "galactic"
-    longitude = "0deg"
-    latitude = "0deg"
-
-Then object information can be obtained by ``get_object(<query>)``.
-Use ``get_object(<name>:<query>)`` for user-defined object information,
-where ``<name>`` must be the name of a TOML file without suffix or the full
-path of it. If it does not exist in a current directory, the function
-will try to find it in the Azely's config directory (``~/.config/azely``).
-
-Examples:
-    To get object info from CDS::
-
-        >>> obj = azely.object.get_object('NGC1068')
-
-    To get object info from ``user.toml``::
-
-        >>> obj = azely.object.get_object('user:GC')
-
-"""
 __all__ = ["Object", "get_object"]
 
 
 # standard library
-from dataclasses import asdict, dataclass
-from typing import Dict, List
+from dataclasses import dataclass
+from typing import List
 
 
 # dependent packages
-from astropy.coordinates import SkyCoord, get_body, solar_system_ephemeris
+from astropy.coordinates import Longitude, Latitude, SkyCoord, get_body
 from astropy.time import Time as ObsTime
-from astropy.coordinates.name_resolve import NameResolveError
-from astropy.utils.data import Conf
+from astropy.utils.data import conf
 from .cache import PathLike, cache
+from .consts import AZELY_OBJECT, FRAME, SOLAR_FRAME, SOLAR_OBJECTS, TIMEOUT
 from .query import parse
-from .utils import AzelyError
 
 
-# constants
-from .consts import (
-    AZELY_DIR,
-    AZELY_OBJECT,
-    FRAME,
-    TIMEOUT,
-)
+@dataclass
+class Object:
+    """Object information."""
 
+    name: str
+    """Name of the object."""
 
-SOLAR_BODIES: List[str] = list(solar_system_ephemeris.bodies)  # type: ignore
-SOLAR_FRAME = "solar"
+    longitude: str
+    """Longitude (e.g. R.A. or l) of the object."""
 
+    latitude: str
+    """Latitude (e.g. Dec. or b) of the object."""
 
-# data classes
-@dataclass(frozen=True)
-class Object:
-    """Azely's object information class."""
+    frame: str
+    """Equatorial coordinates of the object."""
 
-    name: str  #: Object's name.
-    frame: str  #: Name of equatorial coordinates.
-    longitude: str  #: Longitude (e.g., ra or l) with units.
-    latitude: str  #: Latitude (e.g., dec or b) with units.
+    def __post_init__(self) -> None:
+        """Add or update units of object coordinates."""
+        if not self.is_solar:
+            self.longitude = str(Longitude(self.longitude, "hr"))
+            self.latitude = str(Latitude(self.latitude, "deg"))
 
+    @property
     def is_solar(self) -> bool:
-        """Return True if it is an solar object."""
+        """Whether it is a solar object."""
         return self.frame == SOLAR_FRAME
 
-    def to_dict(self) -> Dict[str, str]:
-        """Convert it to a Python's dictionary."""
-        return asdict(self)
-
     def to_skycoord(self, obstime: ObsTime) -> SkyCoord:
-        """Convert it to an astropy's skycoord with given obstime."""
-        if self.is_solar():
-            skycoord = get_body(self.name, time=obstime)
+        """Convert it to a SkyCoord object."""
+        if self.is_solar:
+            skycoord = get_body(
+                body=self.name,
+                time=obstime,
+            )
         else:
-            coords = self.longitude, self.latitude
-            skycoord = SkyCoord(*coords, frame=self.frame, obstime=obstime)
+            skycoord = SkyCoord(
+                self.longitude,
+                self.latitude,
+                frame=self.frame,
+                obstime=obstime,
+            )
 
         skycoord.location = obstime.location
         skycoord.info.name = self.name  # type: ignore
         return skycoord
 
 
-# main functions
-def get_object(query: str, frame: str = FRAME, timeout: int = TIMEOUT) -> Object:
-    """Get object information by various ways.
-
-    This function acquires object information by the following two ways:
-    (1) Data from CDS (by default). Internet connection is required.
-    (2) User-defined object information written in a TOML file.
-
-    In the case of (1), obtained object information is cached in a special
-    TOML file (``~/.config/azely/objects.toml``) for an offline use.
-
-    In the case of (2), users can define object information in a TOML file
-    (e.g., ``user.toml``) which should be put in a current directory or in the
-    Azely's config directory (``~/.config/azely``).
-
-    Then object information can be obtained by ``get_object(<query>)``.
-    Use ``get_object(<name>:<query>)`` for user-defined object information,
-    where ``<name>`` must be the name of a TOML file without suffix or the full
-    path of it. If it does not exist in a current directory, the function
-    will try to find it in the Azely's config directory (``~/.config/azely``).
-
-    Args:
-        query: Query string (e.g., ``'NGC1068'`` or ``'user:GC'``).
-        frame: Name of equatorial coordinates used in astropy's SkyCoord.
-        timeout: Query timeout expressed in units of seconds.
-
-    Returns:
-        Object information as an instance of ``Object`` class.
-
-    Raises:
-        AzelyError: Raised if the function fails to get object information.
-
-    Notes:
-        As ``object`` is the Python's builtin base class, it might be better
-        to use an alternative variable name (e.g., ``object_`` or ``obj``)
-        for object information which this function returns.
-
-    Examples:
-        To get object info from CDS::
-
-            >>> obj = azely.object.get_object('NGC1068')
-
-        To get object info from ``user.toml``::
-
-            >>> obj = azely.object.get_object('user:GC')
-
-    """
+def get_object(
+    query: str,
+    *,
+    frame: str = FRAME,
+    timeout: int = TIMEOUT,
+) -> Object:
+    """Get object information."""
     parsed = parse(query)
 
-    if parsed.query.lower() in SOLAR_BODIES:
+    if parsed.query.lower() in SOLAR_OBJECTS:
         return get_object_solar(
             query=parsed.query,
             source=parsed.source or AZELY_OBJECT,
@@ -176,29 +90,40 @@ def get_object(query: str, frame: str = FRAME, timeout: int = TIMEOUT) -> Object
 @cache
 def get_object_solar(
     query: str,
+    *,
     source: PathLike,  # consumed by @cache
     update: bool,  # consumed by @cache
 ) -> Object:
-    """Get object information of the solar system."""
-    return Object(query.capitalize(), SOLAR_FRAME, "", "")
+    """Get object information in the solar system."""
+    return Object(
+        name=query,
+        longitude="NA",
+        latitude="NA",
+        frame=SOLAR_FRAME,
+    )
 
 
 @cache
 def get_object_by_name(
     query: str,
+    *,
     frame: str,
     timeout: int,
     source: PathLike,  # consumed by @cache
     update: bool,  # consumed by @cache
 ) -> Object:
-    """Get object information from CDS."""
-    with Conf.remote_timeout.set_temp(timeout):  # type: ignore
-        try:
-            res = SkyCoord.from_name(query, frame)
-            lon, lat = res.to_string("hmsdms").split()  # type: ignore
-        except NameResolveError:
-            raise AzelyError(f"Failed to get object: {query}")
-        except ValueError:
-            raise AzelyError(f"Failed to parse frame: {frame}")
-
-    return Object(query, frame, lon, lat)
+    """Get object information by an object name."""
+    with conf.set_temp("remote_timeout", timeout):
+        response = SkyCoord.from_name(
+            name=query,
+            frame=frame,
+            parse=False,
+            cache=False,
+        )
+
+    return Object(
+        name=query,
+        longitude=str(response.data.lon),  # type: ignore
+        latitude=str(response.data.lat),  # type: ignore
+        frame=frame,
+    )


Test patch:
diff --git a/tests/test_object.py b/tests/test_object.py
index 9ba9d22..6286bee 100644
--- a/tests/test_object.py
+++ b/tests/test_object.py
@@ -1,4 +1,5 @@
 # standard library
+from dataclasses import asdict
 from tempfile import NamedTemporaryFile
 
 
@@ -10,16 +11,16 @@ from tomlkit import dump
 # constants
 expected_solar = Object(
     name="Sun",
+    longitude="NA",
+    latitude="NA",
     frame="solar",
-    longitude="",
-    latitude="",
 )
 
 expected_icrs = Object(
     name="M87",
-    frame="icrs",
     longitude="12h30m49.42338414s",
     latitude="+12d23m28.0436859s",
+    frame="icrs",
 )
 
 
@@ -37,7 +38,7 @@ def test_object_by_user():
         name = "M87"
         query = f"{f.name}:{name}"
 
-        dump({name: expected_icrs.to_dict()}, f)
+        dump({name: asdict(expected_icrs)}, f)
         f.seek(0)
 
         assert get_object(query) == expected_icrs