Source code for planetmapper.utils

"""
Various general helpful utilities.
"""

import os
import pathlib
import warnings
from typing import Literal

import matplotlib.ticker
import numpy as np
from matplotlib.axes import Axes


[docs]def format_radec_axes( ax: Axes, dec: float, dms_ticks: bool = True, add_axis_labels: bool = True, aspect_adjustable: Literal['box', 'datalim'] | None = 'datalim', ) -> None: """ Format an axis to display RA/Dec coordinates nicely. Args: ax: Matplotlib axis to format. dec: Declination in degrees of centre of axis. dms_ticks: Toggle between showing ticks as degrees, minutes and seconds (e.g. 12°34′56″) or decimal degrees (e.g. 12.582). add_axis_labels: Add axis labels. aspect_adjustable: Set `adjustable` parameter when setting the aspect ratio. Passed to :func:`matplotlib.axes.Axes.set_aspect`. Set to None to skip setting the aspect ratio (generally this is only recommended if you're setting the aspect ratio yourself). """ if add_axis_labels: ax.set_xlabel('Right Ascension') ax.set_ylabel('Declination') if aspect_adjustable is not None: ax.set_aspect(1 / np.cos(np.deg2rad(dec)), adjustable=aspect_adjustable) if not ax.xaxis_inverted(): ax.invert_xaxis() if dms_ticks: ax.yaxis.set_major_locator(DMSLocator()) ax.yaxis.set_major_formatter(DMSFormatter()) ax.xaxis.set_major_locator(DMSLocator()) ax.xaxis.set_major_formatter(DMSFormatter())
[docs]class DMSFormatter(matplotlib.ticker.FuncFormatter): """ Matplotlib tick formatter to display angular values as degrees, minutes and seconds e.g. `12°34′56″`. Designed to work with :class:`DMSLocator`. :: ax = plt.cga() ax.yaxis.set_major_locator(planetmapper.utils.DMSLocator()) ax.yaxis.set_major_formatter(planetmapper.utils.DMSFormatter()) ax.xaxis.set_major_locator(planetmapper.utils.DMSLocator()) ax.xaxis.set_major_formatter(planetmapper.utils.DMSFormatter()) """ def __init__(self) -> None: super().__init__(self._format) self.skip_parts = set() self.fmt_s = '02.0f' # pylint: disable-next=unused-argument def _format(self, dd, pos): d, m, s = decimal_degrees_to_dms(dd) out = [] if 'd' not in self.skip_parts or (m == 0 and s == 0): out.append(f'{d}°') if 'm' not in self.skip_parts or ('d' in self.skip_parts and s == 0): out.append(f'{m:02.0f}′') if 's' not in self.skip_parts: out.append(f'{s:{self.fmt_s}}″') return ''.join(out) def set_locs(self, locs) -> None: """:meta private:""" vmin, vmax = sorted(self.axis.get_view_interval()) dms_min = decimal_degrees_to_dms(vmin) dms_max = decimal_degrees_to_dms(vmax) vrange = abs(vmax - vmin) self.skip_parts.clear() ofs = '' if dms_min[:2] == dms_max[:2]: d, m, s = dms_min self.skip_parts.add('d') self.skip_parts.add('m') if d != 0 or m != 0: ofs = f'{d:+.0f}°{m:02.0f}′' elif dms_min[0] == dms_max[0]: d, m, s = dms_min self.skip_parts.add('d') if d != 0: ofs = f'{d:+.0f}°' if vrange > 10 / 60: self.skip_parts.add('s') if vrange > 10: self.skip_parts.add('m') if vrange < 10 / 3600: self.skip_parts.add('m') if vrange < 10 / 60: self.skip_parts.add('d') if vrange < 0.01 / 3600: self.fmt_s = '.3g' elif vrange < 0.1 / 3600: self.fmt_s = '.3f' elif vrange < 1 / 3600: self.fmt_s = '.2f' elif vrange < 10 / 3600: self.fmt_s = '.1f' else: self.fmt_s = '02.0f' if self.skip_parts == {'d', 'm', 's'}: self.skip_parts = set() self.set_offset_string(ofs) return super().set_locs(locs)
[docs]class DMSLocator(matplotlib.ticker.Locator): """ Matplotlib tick locator to display angular values as degrees, minutes and seconds. Designed to work with :class:`DMSFormatter`. :: ax = plt.cga() ax.yaxis.set_major_locator(planetmapper.utils.DMSLocator()) ax.yaxis.set_major_formatter(planetmapper.utils.DMSFormatter()) ax.xaxis.set_major_locator(planetmapper.utils.DMSLocator()) ax.xaxis.set_major_formatter(planetmapper.utils.DMSFormatter()) """ def __init__(self) -> None: super().__init__() steps = [1, 2, 5, 10] self.locator = matplotlib.ticker.MaxNLocator(steps=steps, nbins=8) def __call__(self): vmin, vmax = self.axis.get_view_interval() return self.tick_values(vmin, vmax) def tick_values(self, vmin: float, vmax: float) -> np.ndarray: """:meta private:""" vrange = abs(vmax - vmin) if vrange < 1 / 60: multiplier = 3600 elif vrange < 1: multiplier = 60 else: multiplier = 1 ticks = self.locator.tick_values(vmin * multiplier, vmax * multiplier) return ticks / multiplier
[docs]def decimal_degrees_to_dms(decimal_degrees: float) -> tuple[int, int, float]: """ Get degrees, minutes, seconds from decimal degrees. `decimal_degrees_to_dms(-11.111)` returns `(-11.0, 6.0, 39.6)`. Args: decimal_degrees: Decimal degrees. Returns: `(degrees, minutes, seconds)` tuple """ dd = abs(decimal_degrees) minutes, seconds = divmod(dd * 3600, 60) degrees, minutes = divmod(minutes, 60) if decimal_degrees < 0: if degrees: degrees = -degrees elif minutes: minutes = -minutes else: seconds = -seconds return int(degrees), int(minutes), seconds
[docs]def decimal_degrees_to_dms_str(decimal_degrees: float, seconds_fmt: str = '') -> str: """ Create nicely formated DMS string from decimal degrees value (e.g. `'12°34′56″'`). Uses :func:`decimal_degrees_to_dms` to perform the conversion. Args: decimal_degrees: Decimal degrees. seconds_fmt: Optionally specify a format string for the seconds part of the returned value. For example, `seconds_fmt='.3f'` will fix three decimal places for the fractional part of the seconds value. Returns: String representting the degress, minutes, seconds of the angle. """ d, m, s = decimal_degrees_to_dms(decimal_degrees) return f'{d}°{m}{s:{seconds_fmt}}″'
[docs]class ignore_warnings(warnings.catch_warnings): """ Context manager to ignore general warnings using warnings.filterwarnings. """ def __init__(self, *warining_strings: str, **kwargs): super().__init__(**kwargs) self.warning_strings = warining_strings def __enter__(self): out = super().__enter__() for ws in self.warning_strings: warnings.filterwarnings('ignore', ws) return out
[docs]class filter_fits_comment_warning(warnings.catch_warnings): """ Context manager to hide FITS `Card is too long, comment will be truncated` warnings. """ def __enter__(self): out = super().__enter__() warnings.filterwarnings( 'ignore', message='Card is too long, comment will be truncated.', module='astropy.io.fits.card', ) return out
[docs]def normalise( values: np.ndarray | list[float], top: float = 1.0, bottom: float = 0.0, single_value: float | None = None, ) -> np.ndarray: """ Normalise iterable. Args: values: Iterable of values to normalise. top: Top of normalised range. bottom: Bottom of normalised range. single_value: If all values are the same, return this value. Returns: Normalised values. """ assert top > bottom values = np.array(values) if single_value is not None and len(set(values)) == 1: return np.full(values.shape, single_value) vmin = np.nanmin(values) vmax = np.nanmax(values) # Put into 0 to 1 range if vmax != vmin: values = (values - vmin) / (vmax - vmin) #  type: ignore else: values = values - vmin return values * (top - bottom) + bottom #  type: ignore
[docs]def check_path(path: str) -> None: """ Checks if file path's directory tree exists, and creates it if necessary. Assumes path is to a file if `os.path.split(path)[1]` contains '.', otherwise assumes path is to a directory. """ path = os.path.expandvars(os.path.expanduser(path)) if os.path.isdir(path): return if '.' in os.path.split(path)[1]: path = os.path.split(path)[0] if os.path.isdir(path): return if path == '': return print('Creating directory path "{}"'.format(path)) pathlib.Path(path).mkdir(parents=True, exist_ok=True)