Source code for SatelliteCameraViewer.Constellations.ConstellationBoundaries

"""
Constellation Boundaries
"""

# https://watcheroftheskies.net/welcome.html (or https://pbarbier.com/ maybe)
# https://watcheroftheskies.net/constellations/boundaries.html
#
# Interpolated merged edges (J2000, dec) ...
# https://watcheroftheskies.net/constellations/lines_in_20.txt
#
# Bytes Format  Unit    Explanation
# 1-10  F10.7   hrs     Right ascension J2000 (decimal hours)
# 12    A1              Declination J2000 (sign)
# 13-21 F9.6    deg     Declination J2000 (decimal degrees)
# 23-29 A7              Segment key
#
#  0.1022250 -81.803966 559:560
#  0.1068476 +10.696028 673:672
# 16.5663214 -42.267474 612:628
# 23.4455593 -36.312778 510:517
#
# See https://watcheroftheskies.net/constellations/9N.pdf for Orion map
#

import os
import math
from datetime import datetime
from pathlib import Path
from dataclasses import dataclass

import requests

[docs] @dataclass class ConstellationBoundary: """ ConstellationBoundary """ ra_deg: float = 0.0 dec_deg: float = 0.0 segment_key: str = None
[docs] class ConstellationBoundariesError(Exception): """ ConstellationBoundariesError """
[docs] class ConstellationBoundaries: """ ConstellationBoundaries """ _BASE_URL = 'https://watcheroftheskies.net/constellations' _NAME_J2000_BOUNDARIES = 'bound_in_20.txt' # Interpolated boundaries (J2000, ccw, dec) _NAME_J2000_MERGED_EDGES = 'lines_in_20.txt' # Interpolated merged edges (J2000, dec) _NAME_J2000_CONSTELLATION_CENTERS = 'centers_20.txt' # Constellation centers (J2000, dec) _DIR_ConstellationBoundaries = '~/.cache/constellation-boundaries' def __init__(self, directory=None): """ ConstellationBoundaries """ self._base_url = self._BASE_URL # we only process one of the image files on that website for now self._j2000_merged_edges = self._NAME_J2000_MERGED_EDGES if directory: self._directory = directory else: self._directory = os.getenv('CONSTELLATION_BOUNDARIES') if not self._directory: self._directory = self._DIR_ConstellationBoundaries self._directory = Path(self._directory).expanduser() self._directory.mkdir(parents=True, exist_ok=True) if not self._directory.exists(): raise ConstellationBoundariesError('%s: directory does not exist' % (self._directory)) from None self._read() def _read(self): """ _read() """ # first pass - just try the local file - mostly successful after first run try: r = self._readfile() if r == 0: # a cheap way to continue to the network raise FileNotFoundError('zero length file') from None # we are successfully done! return except FileNotFoundError: # lets continue to the network section of the code and not throw an error pass except PermissionError: # hard to fix anything at this point - humans need to get involved raise ConstellationBoundariesError('%s: permission error - file not readable' % (self._local_filename)) from None # second pass - go out to network and fetch contents try: r1 = self._network_fetch() except requests.exceptions.Timeout: # classic can't connect issue with timeout self._local_filename.unlink(missing_ok=True) raise ConstellationBoundariesError('HTTP Error %s: %s' % ('Timeout', self._url)) from None except requests.exceptions.ConnectionError as e: # classic can't connect issue self._local_filename.unlink(missing_ok=True) try: root_os_error = e.args[0].reason.args[0] except (AttributeError, IndexError): root_os_error = e raise ConstellationBoundariesError('HTTP Error %s: %s' % (root_os_error, self._url)) from None except requests.exceptions.HTTPError as e: # this would be something like a 404 (Not Found) or 406 (Not Acceptable) response self._local_filename.unlink(missing_ok=True) if 400 <= e.response.status_code < 500: raise ConstellationBoundariesError('HTTP Client Error %d: %s' % (e.response.status_code, self._url)) from None raise ConstellationBoundariesError('HTTP Server Error %d: %s' % (e.response.status_code, self._url)) from None except requests.exceptions.RequestException: # something else happened - not great! self._local_filename.unlink(missing_ok=True) # let it pass up - it could be important to see what happened raise # a special case cleanup for network code - if a zero length result is returned if r1 == 0: # could happen - better clean up - just in case self._local_filename.unlink(missing_ok=True) raise ConstellationBoundariesError('network fetch zero length file') from None # third pass - back to the local file as-created by network code try: r2 = self._readfile() except FileNotFoundError: raise ConstellationBoundariesError('%s: file does not exist' % (self._local_filename)) from None # a sanity check to confirm that the network and the local file agree (in the simplest possible way) if r1 != r2: raise ConstellationBoundariesError('inconsistent length file') from None # we are successfully done! def _readfile(self): """ _readfile() """ self._a = {} n_bytes = 0 with self._local_filename.open('r', encoding='utf-8') as fd: for line in fd: n_bytes += len(line) # no need to strip the line as we are very explcit about the line usage ... ra_deg = float(line[0:10]) * 15.0 # converted from hours to degrees dec_deg = float(line[11:21]) segment_key = line[22:29] line = ConstellationBoundary(ra_deg, dec_deg, segment_key) if segment_key in self._a: self._a[segment_key].append(line) else: self._a[segment_key] = [line] return n_bytes def _network_fetch(self): """ _network_fetch() """ headers = { # required to make website respond cleanly 'Accept-Encoding': 'text/plain', 'Referer': 'https://google.com/', 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0', } response = requests.get(self._url, headers=headers, stream=True, timeout=20) response.raise_for_status() # partial network success at this point n_bytes = 0 with self._local_filename.open('wb') as fd: for chunk in response.iter_content(chunk_size=32*1024): if chunk: fd.write(chunk) n_bytes += len(chunk) # total network success at this point # we should try to date the file correctly (do this after the file is closed above) try: # should be "Last-Modified: Mon, 31 Jan 2000 12:00:00 EST" style if headers are correct last_modified = response.headers['Last-Modified'] last_modified_dt = datetime.strptime(last_modified, "%a, %d %b %Y %H:%M:%S %Z") last_modified_ts = int(last_modified_dt.timestamp()) # change both atime and mtime on file os.utime(self._local_filename, (last_modified_ts, last_modified_ts)) except (IndexError, ValueError): # if the header is missing, or badly formatted, we just continue pass # total network success and hopefully timestamp success at this point return n_bytes @property def _local_filename(self): """ _local_filename() """ return self._directory / self._j2000_merged_edges @property def _url(self): """ _url() """ return self._base_url + '/' + self._j2000_merged_edges
[docs] def data(self): """ data() """ return self._a
[docs] def data2plot(self): """ data2plot() """ r = [] # TODO - remove this and move to main code - plus use numpy def ra_fix(ra_rad): """ ra_fix """ return -((ra_rad + math.pi) % (2 * math.pi) - math.pi) # shift to [-pi, pi] for lines in self._a.values(): x = [] y = [] for line in lines: x.append(ra_fix(math.radians(line.ra_deg))) y.append(math.radians(line.dec_deg)) r.append((x,y)) return r
def _main(args=None): """ _main """ import sys # pylint: disable=C0415 import matplotlib.pyplot as plt # pylint: disable=C0415 try: cb = ConstellationBoundaries() except ConstellationBoundariesError as e: sys.exit(e) except KeyboardInterrupt as e: sys.exit(e) fig = plt.figure(figsize=(14, 7)) fig.patch.set_linewidth(0) fig.tight_layout(pad=0.0, h_pad=0.0, w_pad=0.0) fig.set_layout_engine(layout='tight') fig.patch.set_facecolor('#ebebeb') ax = fig.add_subplot(111, projection='mollweide') ax.set_facecolor('whitesmoke') ax.grid(color='lightgrey', alpha=0.5) for segment in cb.data2plot(): ax.plot(segment[0], segment[1], label='Constellation Boundary', color='lightblue', alpha=0.75, linewidth=1, linestyle='dashed') ax.grid(True) ax.set_title('Constellation Boundaries') plt.show() if __name__ == '__main__': _main()